Spaces:
Sleeping
Sleeping
Update
Browse files- Akompta/settings.py +10 -1
- api/syscohada_reports.py +313 -0
- api/syscohada_templates/bilan_structure.json +64 -0
- api/syscohada_templates/compte_resultat_structure.json +47 -0
- api/tests_new.py +37 -0
- api/urls.py +4 -0
- api/views.py +48 -1
Akompta/settings.py
CHANGED
|
@@ -25,8 +25,17 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|
| 25 |
# SECURITY WARNING: keep the secret key used in production secret!
|
| 26 |
SECRET_KEY = config('SECRET_KEY', default='django-insecure-3m1!a3u-z=5k8x9y#-954&3ree&mr&$o97fuy8ds*8dox!(rvx')
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
# SECURITY WARNING: don't run with debug turned on in production!
|
| 29 |
-
DEBUG = config('DEBUG', default=True, cast=
|
| 30 |
|
| 31 |
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv())
|
| 32 |
|
|
|
|
| 25 |
# SECURITY WARNING: keep the secret key used in production secret!
|
| 26 |
SECRET_KEY = config('SECRET_KEY', default='django-insecure-3m1!a3u-z=5k8x9y#-954&3ree&mr&$o97fuy8ds*8dox!(rvx')
|
| 27 |
|
| 28 |
+
# decouple's built-in bool cast is strict and can crash on values like "release".
|
| 29 |
+
def _parse_bool(value):
|
| 30 |
+
s = str(value).strip().lower()
|
| 31 |
+
if s in {"1", "true", "yes", "y", "on"}:
|
| 32 |
+
return True
|
| 33 |
+
if s in {"0", "false", "no", "n", "off", ""}:
|
| 34 |
+
return False
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
# SECURITY WARNING: don't run with debug turned on in production!
|
| 38 |
+
DEBUG = config('DEBUG', default=True, cast=_parse_bool)
|
| 39 |
|
| 40 |
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv())
|
| 41 |
|
api/syscohada_reports.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from decimal import Decimal
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
from django.utils import timezone
|
| 14 |
+
|
| 15 |
+
from .models import Transaction, User
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
TEMPLATES_DIR = Path(__file__).resolve().parent / "syscohada_templates"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _load_template_json(filename: str) -> Any:
|
| 22 |
+
with (TEMPLATES_DIR / filename).open("r", encoding="utf-8") as f:
|
| 23 |
+
return json.load(f)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _year_bounds(year: int) -> tuple[datetime, datetime]:
|
| 27 |
+
start = timezone.make_aware(datetime(year, 1, 1, 0, 0, 0))
|
| 28 |
+
end = timezone.make_aware(datetime(year + 1, 1, 1, 0, 0, 0))
|
| 29 |
+
return start, end
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _normalize_text(value: str | None) -> str:
|
| 33 |
+
return (value or "").strip().lower()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _map_transaction_to_cr_ref(tx: Transaction) -> str:
|
| 37 |
+
"""
|
| 38 |
+
MVP mapping: map Akompta Transaction -> SYSCOHADA Compte de Résultat ref.
|
| 39 |
+
This is intentionally heuristic and can be replaced later by a user-configurable mapping table.
|
| 40 |
+
"""
|
| 41 |
+
category = _normalize_text(getattr(tx, "category", ""))
|
| 42 |
+
name = _normalize_text(getattr(tx, "name", ""))
|
| 43 |
+
haystack = f"{category} {name}".strip()
|
| 44 |
+
|
| 45 |
+
if tx.type == "income":
|
| 46 |
+
if any(k in haystack for k in ["service", "prestation", "consult"]):
|
| 47 |
+
return "TC" # travaux, services vendus
|
| 48 |
+
if any(k in haystack for k in ["produit accessoire", "accessoire"]):
|
| 49 |
+
return "TD"
|
| 50 |
+
return "TA" # ventes de marchandises
|
| 51 |
+
|
| 52 |
+
# expense
|
| 53 |
+
if any(k in haystack for k in ["achat", "marchandise", "appro", "approvisionnement"]):
|
| 54 |
+
return "RA"
|
| 55 |
+
if any(k in haystack for k in ["transport", "taxi", "bus", "essence", "carburant", "livraison"]):
|
| 56 |
+
return "RG"
|
| 57 |
+
if any(k in haystack for k in ["loyer", "internet", "eau", "electric", "électric", "telephone", "téléphone", "prestataire", "maintenance", "marketing", "publicit", "pub"]):
|
| 58 |
+
return "RH"
|
| 59 |
+
if any(k in haystack for k in ["impot", "impôt", "taxe", "douane", "etat", "état"]):
|
| 60 |
+
return "RI"
|
| 61 |
+
if any(k in haystack for k in ["salaire", "salaires", "personnel", "paie", "payroll"]):
|
| 62 |
+
return "RK"
|
| 63 |
+
return "RJ"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
_CR_TOKEN_RE = re.compile(r"([A-Z]{1,2})|([+-])")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _eval_cr_formula(formula: str, values: dict[str, Decimal]) -> Decimal:
|
| 70 |
+
"""
|
| 71 |
+
Evaluate formulas like: "XB-RA+RB+TE-RE" using Decimal arithmetic.
|
| 72 |
+
Supports only refs (A-Z, 1-2 chars) and + / -.
|
| 73 |
+
"""
|
| 74 |
+
tokens = [m.group(0) for m in _CR_TOKEN_RE.finditer(formula.replace(" ", ""))]
|
| 75 |
+
if not tokens:
|
| 76 |
+
return Decimal("0")
|
| 77 |
+
|
| 78 |
+
total = Decimal("0")
|
| 79 |
+
op = "+"
|
| 80 |
+
for tok in tokens:
|
| 81 |
+
if tok in {"+", "-"}:
|
| 82 |
+
op = tok
|
| 83 |
+
continue
|
| 84 |
+
value = values.get(tok, Decimal("0"))
|
| 85 |
+
total = total + value if op == "+" else total - value
|
| 86 |
+
return total
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@dataclass(frozen=True)
|
| 90 |
+
class CompteResultatComputed:
|
| 91 |
+
year: int
|
| 92 |
+
values_n: dict[str, Decimal]
|
| 93 |
+
values_n_1: dict[str, Decimal]
|
| 94 |
+
resultat_net_n: Decimal
|
| 95 |
+
total_income_n: Decimal
|
| 96 |
+
total_expense_n: Decimal
|
| 97 |
+
total_income_n_1: Decimal
|
| 98 |
+
total_expense_n_1: Decimal
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def compute_compte_resultat(user: User, year: int) -> CompteResultatComputed:
|
| 102 |
+
structure = _load_template_json("compte_resultat_structure.json")
|
| 103 |
+
lignes: list[dict[str, Any]] = structure["lignes"]
|
| 104 |
+
|
| 105 |
+
start_n, end_n = _year_bounds(year)
|
| 106 |
+
start_n_1, end_n_1 = _year_bounds(year - 1)
|
| 107 |
+
|
| 108 |
+
tx_n = Transaction.objects.filter(user=user, date__gte=start_n, date__lt=end_n).only(
|
| 109 |
+
"amount",
|
| 110 |
+
"type",
|
| 111 |
+
"category",
|
| 112 |
+
"name",
|
| 113 |
+
)
|
| 114 |
+
tx_n_1 = Transaction.objects.filter(user=user, date__gte=start_n_1, date__lt=end_n_1).only(
|
| 115 |
+
"amount",
|
| 116 |
+
"type",
|
| 117 |
+
"category",
|
| 118 |
+
"name",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
values_n: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in lignes}
|
| 122 |
+
values_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in lignes}
|
| 123 |
+
|
| 124 |
+
total_income_n = Decimal("0")
|
| 125 |
+
total_expense_n = Decimal("0")
|
| 126 |
+
for tx in tx_n:
|
| 127 |
+
ref = _map_transaction_to_cr_ref(tx)
|
| 128 |
+
amount = Decimal(tx.amount)
|
| 129 |
+
values_n[ref] = values_n.get(ref, Decimal("0")) + amount
|
| 130 |
+
if tx.type == "income":
|
| 131 |
+
total_income_n += amount
|
| 132 |
+
else:
|
| 133 |
+
total_expense_n += amount
|
| 134 |
+
|
| 135 |
+
total_income_n_1 = Decimal("0")
|
| 136 |
+
total_expense_n_1 = Decimal("0")
|
| 137 |
+
for tx in tx_n_1:
|
| 138 |
+
ref = _map_transaction_to_cr_ref(tx)
|
| 139 |
+
amount = Decimal(tx.amount)
|
| 140 |
+
values_n_1[ref] = values_n_1.get(ref, Decimal("0")) + amount
|
| 141 |
+
if tx.type == "income":
|
| 142 |
+
total_income_n_1 += amount
|
| 143 |
+
else:
|
| 144 |
+
total_expense_n_1 += amount
|
| 145 |
+
|
| 146 |
+
# Compute total lines in order (formulas reference previous totals, order in structure matters)
|
| 147 |
+
for item in lignes:
|
| 148 |
+
if not item.get("is_total"):
|
| 149 |
+
continue
|
| 150 |
+
formula = item.get("formula") or ""
|
| 151 |
+
values_n[item["ref"]] = _eval_cr_formula(formula, values_n)
|
| 152 |
+
values_n_1[item["ref"]] = _eval_cr_formula(formula, values_n_1)
|
| 153 |
+
|
| 154 |
+
# For MVP, use the computed XI if available; fallback to income-expense.
|
| 155 |
+
resultat_net_n = values_n.get("XI")
|
| 156 |
+
if resultat_net_n is None:
|
| 157 |
+
resultat_net_n = total_income_n - total_expense_n
|
| 158 |
+
|
| 159 |
+
return CompteResultatComputed(
|
| 160 |
+
year=year,
|
| 161 |
+
values_n=values_n,
|
| 162 |
+
values_n_1=values_n_1,
|
| 163 |
+
resultat_net_n=resultat_net_n,
|
| 164 |
+
total_income_n=total_income_n,
|
| 165 |
+
total_expense_n=total_expense_n,
|
| 166 |
+
total_income_n_1=total_income_n_1,
|
| 167 |
+
total_expense_n_1=total_expense_n_1,
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def generate_compte_resultat_csv(compte: CompteResultatComputed) -> bytes:
|
| 172 |
+
structure = _load_template_json("compte_resultat_structure.json")
|
| 173 |
+
lignes: list[dict[str, Any]] = structure["lignes"]
|
| 174 |
+
|
| 175 |
+
out = io.StringIO()
|
| 176 |
+
writer = csv.writer(out)
|
| 177 |
+
writer.writerow(["REF", "LIBELLES", "NUMERO DE COMPTES", "MONTANT_N", "MONTANT_N_1"])
|
| 178 |
+
for item in lignes:
|
| 179 |
+
ref = item["ref"]
|
| 180 |
+
writer.writerow(
|
| 181 |
+
[
|
| 182 |
+
ref,
|
| 183 |
+
item.get("libelle", ""),
|
| 184 |
+
item.get("compte", ""),
|
| 185 |
+
str(compte.values_n.get(ref, Decimal("0"))),
|
| 186 |
+
str(compte.values_n_1.get(ref, Decimal("0"))),
|
| 187 |
+
]
|
| 188 |
+
)
|
| 189 |
+
return out.getvalue().encode("utf-8")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def generate_bilan_csv(user: User, compte: CompteResultatComputed) -> bytes:
|
| 193 |
+
structure = _load_template_json("bilan_structure.json")
|
| 194 |
+
actif: list[dict[str, Any]] = structure["actif"]
|
| 195 |
+
passif: list[dict[str, Any]] = structure["passif"]
|
| 196 |
+
|
| 197 |
+
# Minimal model: only cash + equity + result to keep the bilan balanced.
|
| 198 |
+
cash_n = user.initial_balance + compte.total_income_n - compte.total_expense_n
|
| 199 |
+
cash_n_1 = user.initial_balance + compte.total_income_n_1 - compte.total_expense_n_1
|
| 200 |
+
|
| 201 |
+
capital_n = user.initial_balance
|
| 202 |
+
capital_n_1 = user.initial_balance
|
| 203 |
+
|
| 204 |
+
# Actif values stored by ref: BRUT, AMORT, NET_N, NET_N_1
|
| 205 |
+
brut: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
|
| 206 |
+
amort: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
|
| 207 |
+
|
| 208 |
+
brut["BS"] = Decimal(cash_n)
|
| 209 |
+
amort["BS"] = Decimal("0")
|
| 210 |
+
|
| 211 |
+
brut_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
|
| 212 |
+
amort_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
|
| 213 |
+
brut_n_1["BS"] = Decimal(cash_n_1)
|
| 214 |
+
amort_n_1["BS"] = Decimal("0")
|
| 215 |
+
|
| 216 |
+
def net_for(ref: str) -> Decimal:
|
| 217 |
+
return brut.get(ref, Decimal("0")) - amort.get(ref, Decimal("0"))
|
| 218 |
+
|
| 219 |
+
def net_for_n_1(ref: str) -> Decimal:
|
| 220 |
+
return brut_n_1.get(ref, Decimal("0")) - amort_n_1.get(ref, Decimal("0"))
|
| 221 |
+
|
| 222 |
+
# Compute header subtotals (stable SYSCOHADA groupings)
|
| 223 |
+
header_groups = {
|
| 224 |
+
"AD": ["AE", "AF", "AG", "AH"],
|
| 225 |
+
"AI": ["AJ", "AK", "AL", "AM", "AN", "AP"],
|
| 226 |
+
"AQ": ["AR", "AS"],
|
| 227 |
+
"BG": ["BH", "BI", "BJ"],
|
| 228 |
+
}
|
| 229 |
+
for header_ref, children in header_groups.items():
|
| 230 |
+
brut[header_ref] = sum((brut.get(c, Decimal("0")) for c in children), Decimal("0"))
|
| 231 |
+
amort[header_ref] = sum((amort.get(c, Decimal("0")) for c in children), Decimal("0"))
|
| 232 |
+
brut_n_1[header_ref] = sum((brut_n_1.get(c, Decimal("0")) for c in children), Decimal("0"))
|
| 233 |
+
amort_n_1[header_ref] = sum((amort_n_1.get(c, Decimal("0")) for c in children), Decimal("0"))
|
| 234 |
+
|
| 235 |
+
# Compute totals based on formulas (only '+' is expected in these bilan totals)
|
| 236 |
+
def parse_bilan_sum_formula(formula: str) -> list[str]:
|
| 237 |
+
return [part.strip() for part in formula.split("+") if part.strip()]
|
| 238 |
+
|
| 239 |
+
for item in actif:
|
| 240 |
+
if not item.get("is_total"):
|
| 241 |
+
continue
|
| 242 |
+
parts = parse_bilan_sum_formula(item.get("formula", ""))
|
| 243 |
+
brut[item["ref"]] = sum((brut.get(p, Decimal("0")) for p in parts), Decimal("0"))
|
| 244 |
+
amort[item["ref"]] = sum((amort.get(p, Decimal("0")) for p in parts), Decimal("0"))
|
| 245 |
+
brut_n_1[item["ref"]] = sum((brut_n_1.get(p, Decimal("0")) for p in parts), Decimal("0"))
|
| 246 |
+
amort_n_1[item["ref"]] = sum((amort_n_1.get(p, Decimal("0")) for p in parts), Decimal("0"))
|
| 247 |
+
|
| 248 |
+
# Passif values: NET only, but handle "negative" lines like CB by applying sign.
|
| 249 |
+
passif_meta: dict[str, dict[str, Any]] = {item["ref"]: item for item in passif}
|
| 250 |
+
net_passif_n: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
|
| 251 |
+
net_passif_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
|
| 252 |
+
|
| 253 |
+
net_passif_n["CA"] = Decimal(capital_n)
|
| 254 |
+
net_passif_n_1["CA"] = Decimal(capital_n_1)
|
| 255 |
+
|
| 256 |
+
net_passif_n["CJ"] = Decimal(compte.resultat_net_n)
|
| 257 |
+
net_passif_n_1["CJ"] = Decimal("0") # not computed in this MVP
|
| 258 |
+
|
| 259 |
+
def signed_passif_value(values: dict[str, Decimal], ref: str) -> Decimal:
|
| 260 |
+
val = values.get(ref, Decimal("0"))
|
| 261 |
+
meta = passif_meta.get(ref, {})
|
| 262 |
+
if meta.get("is_negative"):
|
| 263 |
+
return -val
|
| 264 |
+
return val
|
| 265 |
+
|
| 266 |
+
def eval_passif_formula(values: dict[str, Decimal], formula: str) -> Decimal:
|
| 267 |
+
parts = parse_bilan_sum_formula(formula)
|
| 268 |
+
return sum((signed_passif_value(values, p) for p in parts), Decimal("0"))
|
| 269 |
+
|
| 270 |
+
for item in passif:
|
| 271 |
+
if not item.get("is_total"):
|
| 272 |
+
continue
|
| 273 |
+
ref = item["ref"]
|
| 274 |
+
net_passif_n[ref] = eval_passif_formula(net_passif_n, item.get("formula", ""))
|
| 275 |
+
net_passif_n_1[ref] = eval_passif_formula(net_passif_n_1, item.get("formula", ""))
|
| 276 |
+
|
| 277 |
+
# Build a single CSV containing both sections.
|
| 278 |
+
out = io.StringIO()
|
| 279 |
+
writer = csv.writer(out)
|
| 280 |
+
writer.writerow(["SECTION", "REF", "LIBELLE", "NOTE", "BRUT", "AMORT/DEPREC", "NET_N", "NET_N_1"])
|
| 281 |
+
|
| 282 |
+
for item in actif:
|
| 283 |
+
ref = item["ref"]
|
| 284 |
+
writer.writerow(
|
| 285 |
+
[
|
| 286 |
+
"ACTIF",
|
| 287 |
+
ref,
|
| 288 |
+
item.get("libelle", ""),
|
| 289 |
+
item.get("note", ""),
|
| 290 |
+
str(brut.get(ref, Decimal("0"))),
|
| 291 |
+
str(amort.get(ref, Decimal("0"))),
|
| 292 |
+
str(net_for(ref)),
|
| 293 |
+
str(net_for_n_1(ref)),
|
| 294 |
+
]
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
for item in passif:
|
| 298 |
+
ref = item["ref"]
|
| 299 |
+
writer.writerow(
|
| 300 |
+
[
|
| 301 |
+
"PASSIF",
|
| 302 |
+
ref,
|
| 303 |
+
item.get("libelle", ""),
|
| 304 |
+
item.get("note", ""),
|
| 305 |
+
"",
|
| 306 |
+
"",
|
| 307 |
+
str(signed_passif_value(net_passif_n, ref)),
|
| 308 |
+
str(signed_passif_value(net_passif_n_1, ref)),
|
| 309 |
+
]
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
return out.getvalue().encode("utf-8")
|
| 313 |
+
|
api/syscohada_templates/bilan_structure.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"actif": [
|
| 3 |
+
{"ref": "AD", "libelle": "IMMOBILISATIONS INCORPORELLES", "note": "3", "is_total": false, "is_header": true},
|
| 4 |
+
{"ref": "AE", "libelle": "Frais de développement et de prospection", "note": "", "is_total": false},
|
| 5 |
+
{"ref": "AF", "libelle": "Brevets, licences, logiciels, et droits similaires", "note": "", "is_total": false},
|
| 6 |
+
{"ref": "AG", "libelle": "Fonds commercial et droit au bail", "note": "", "is_total": false},
|
| 7 |
+
{"ref": "AH", "libelle": "Autres immobilisations incorporelles", "note": "", "is_total": false},
|
| 8 |
+
{"ref": "AI", "libelle": "IMMOBILISATIONS CORPORELLES", "note": "3", "is_total": false, "is_header": true},
|
| 9 |
+
{"ref": "AJ", "libelle": "Terrains (1)", "note": "", "is_total": false},
|
| 10 |
+
{"ref": "AK", "libelle": "Bâtiments (1)", "note": "", "is_total": false},
|
| 11 |
+
{"ref": "AL", "libelle": "Aménagements, agencements et installations", "note": "", "is_total": false},
|
| 12 |
+
{"ref": "AM", "libelle": "Matériel, mobilier et actifs biologiques", "note": "", "is_total": false},
|
| 13 |
+
{"ref": "AN", "libelle": "Matériel de transport", "note": "", "is_total": false},
|
| 14 |
+
{"ref": "AP", "libelle": "Avances et acomptes versés sur immobilisations", "note": "3", "is_total": false},
|
| 15 |
+
{"ref": "AQ", "libelle": "IMMOBILISATIONS FINANCIERES", "note": "4", "is_total": false, "is_header": true},
|
| 16 |
+
{"ref": "AR", "libelle": "Titres de participation", "note": "", "is_total": false},
|
| 17 |
+
{"ref": "AS", "libelle": "Autres immobilisations financières", "note": "", "is_total": false},
|
| 18 |
+
{"ref": "AZ", "libelle": "TOTAL ACTIF IMMOBILISE", "note": "", "is_total": true, "formula": "AD+AI+AQ"},
|
| 19 |
+
{"ref": "BA", "libelle": "ACTIF CIRCULANT HAO", "note": "5", "is_total": false},
|
| 20 |
+
{"ref": "BB", "libelle": "STOCKS ET ENCOURS", "note": "6", "is_total": false},
|
| 21 |
+
{"ref": "BG", "libelle": "CREANCES ET EMPLOIS ASSIMILES", "note": "", "is_total": false, "is_header": true},
|
| 22 |
+
{"ref": "BH", "libelle": "Fournisseurs avances versées", "note": "17", "is_total": false},
|
| 23 |
+
{"ref": "BI", "libelle": "Clients", "note": "7", "is_total": false},
|
| 24 |
+
{"ref": "BJ", "libelle": "Autres créances", "note": "8", "is_total": false},
|
| 25 |
+
{"ref": "BK", "libelle": "TOTAL ACTIF CIRCULANT", "note": "", "is_total": true, "formula": "BA+BB+BG"},
|
| 26 |
+
{"ref": "BQ", "libelle": "Titres de placement", "note": "9", "is_total": false},
|
| 27 |
+
{"ref": "BR", "libelle": "Valeurs à encaisser", "note": "10", "is_total": false},
|
| 28 |
+
{"ref": "BS", "libelle": "Banques, chèques postaux, caisse et assimilés", "note": "11", "is_total": false},
|
| 29 |
+
{"ref": "BT", "libelle": "TOTAL TRESORERIE-ACTIF", "note": "", "is_total": true, "formula": "BQ+BR+BS"},
|
| 30 |
+
{"ref": "BU", "libelle": "Ecart de conversion-Actif", "note": "12", "is_total": false},
|
| 31 |
+
{"ref": "BZ", "libelle": "TOTAL GENERAL", "note": "", "is_total": true, "formula": "AZ+BK+BT+BU"}
|
| 32 |
+
],
|
| 33 |
+
"passif": [
|
| 34 |
+
{"ref": "CA", "libelle": "Capital", "note": "13", "is_total": false},
|
| 35 |
+
{"ref": "CB", "libelle": "Apporteurs capital non appelé (-)", "note": "13", "is_total": false, "is_negative": true},
|
| 36 |
+
{"ref": "CD", "libelle": "Primes liées au capital social", "note": "14", "is_total": false},
|
| 37 |
+
{"ref": "CE", "libelle": "Ecarts de réévaluation", "note": "3e", "is_total": false},
|
| 38 |
+
{"ref": "CF", "libelle": "Réserves indisponibles", "note": "14", "is_total": false},
|
| 39 |
+
{"ref": "CG", "libelle": "Réserves libres", "note": "14", "is_total": false},
|
| 40 |
+
{"ref": "CH", "libelle": "Report à nouveau (+ ou -)", "note": "14", "is_total": false},
|
| 41 |
+
{"ref": "CJ", "libelle": "Résultat net de l'exercice (bénéfice + ou perte -)", "note": "", "is_total": false},
|
| 42 |
+
{"ref": "CL", "libelle": "Subventions d'investissement", "note": "15", "is_total": false},
|
| 43 |
+
{"ref": "CM", "libelle": "Provisions réglementées", "note": "15", "is_total": false},
|
| 44 |
+
{"ref": "CP", "libelle": "TOTAL CAPITAUX PROPRES ET RESSOURCES ASSIMILEES", "note": "", "is_total": true, "formula": "CA+CB+CD+CE+CF+CG+CH+CJ+CL+CM"},
|
| 45 |
+
{"ref": "DA", "libelle": "Emprunts et dettes financières diverses", "note": "16", "is_total": false},
|
| 46 |
+
{"ref": "DB", "libelle": "Dettes de location acquisition", "note": "16", "is_total": false},
|
| 47 |
+
{"ref": "DC", "libelle": "Provisions pour risques et charges", "note": "16", "is_total": false},
|
| 48 |
+
{"ref": "DD", "libelle": "TOTAL DETTES FINANCIERES ET RESSOURCES ASSIMILEES", "note": "", "is_total": true, "formula": "DA+DB+DC"},
|
| 49 |
+
{"ref": "DF", "libelle": "TOTAL RESSOURCES STABLES", "note": "", "is_total": true, "formula": "CP+DD"},
|
| 50 |
+
{"ref": "DH", "libelle": "Dettes circulantes HAO", "note": "5", "is_total": false},
|
| 51 |
+
{"ref": "DI", "libelle": "Clients, avances reçues", "note": "7", "is_total": false},
|
| 52 |
+
{"ref": "DJ", "libelle": "Fournisseurs d'exploitation", "note": "17", "is_total": false},
|
| 53 |
+
{"ref": "DK", "libelle": "Dettes fiscales et sociales", "note": "18", "is_total": false},
|
| 54 |
+
{"ref": "DM", "libelle": "Autres dettes", "note": "19", "is_total": false},
|
| 55 |
+
{"ref": "DN", "libelle": "Provisions pour risques à court terme", "note": "19", "is_total": false},
|
| 56 |
+
{"ref": "DP", "libelle": "TOTAL PASSIF CIRCULANT", "note": "", "is_total": true, "formula": "DH+DI+DJ+DK+DM+DN"},
|
| 57 |
+
{"ref": "DQ", "libelle": "Banques, crédits d'escompte", "note": "20", "is_total": false},
|
| 58 |
+
{"ref": "DR", "libelle": "Banques, établissements financiers et crédits de trésorerie", "note": "20", "is_total": false},
|
| 59 |
+
{"ref": "DT", "libelle": "TOTAL TRESORERIE-PASSIF", "note": "", "is_total": true, "formula": "DQ+DR"},
|
| 60 |
+
{"ref": "DV", "libelle": "Ecart de conversion-Passif", "note": "12", "is_total": false},
|
| 61 |
+
{"ref": "DZ", "libelle": "TOTAL GENERAL", "note": "", "is_total": true, "formula": "DF+DP+DT+DV"}
|
| 62 |
+
]
|
| 63 |
+
}
|
| 64 |
+
|
api/syscohada_templates/compte_resultat_structure.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lignes": [
|
| 3 |
+
{"ref": "TA", "libelle": "Ventes de marchandises", "compte": "701", "type": "produit", "is_total": false},
|
| 4 |
+
{"ref": "RA", "libelle": "Achats de marchandises", "compte": "601", "type": "charge", "is_total": false},
|
| 5 |
+
{"ref": "RB", "libelle": "Variation de stocks de marchandises", "compte": "6031", "type": "mixte", "is_total": false},
|
| 6 |
+
{"ref": "XA", "libelle": "MARGE COMMERCIALE (Somme TA à RB)", "formula": "TA-RA+RB", "is_total": true, "bg_color": "#DDEBF7"},
|
| 7 |
+
{"ref": "TB", "libelle": "Ventes de produits fabriqués", "compte": "702, 703, 704", "type": "produit", "is_total": false},
|
| 8 |
+
{"ref": "TC", "libelle": "Travaux, services vendus", "compte": "705, 706", "type": "produit", "is_total": false},
|
| 9 |
+
{"ref": "TD", "libelle": "Produits accessoires", "compte": "707", "type": "produit", "is_total": false},
|
| 10 |
+
{"ref": "XB", "libelle": "CHIFFRE D'AFFAIRES (A + B + C + D)", "formula": "TA+TB+TC+TD", "is_total": true, "bg_color": "#DDEBF7"},
|
| 11 |
+
{"ref": "TE", "libelle": "Production stockée (ou déstockage)", "compte": "73", "type": "produit", "is_total": false},
|
| 12 |
+
{"ref": "TF", "libelle": "Production immobilisée", "compte": "72", "type": "produit", "is_total": false},
|
| 13 |
+
{"ref": "TG", "libelle": "Subventions d'exploitation", "compte": "71", "type": "produit", "is_total": false},
|
| 14 |
+
{"ref": "TH", "libelle": "Autres produits", "compte": "75", "type": "produit", "is_total": false},
|
| 15 |
+
{"ref": "TI", "libelle": "Transferts de charges d'exploitation", "compte": "781", "type": "produit", "is_total": false},
|
| 16 |
+
{"ref": "RC", "libelle": "Achats de matières premières et fournitures liées", "compte": "602", "type": "charge", "is_total": false},
|
| 17 |
+
{"ref": "RD", "libelle": "Variation de stocks de matières premières et fournitures liées", "compte": "6032", "type": "mixte", "is_total": false},
|
| 18 |
+
{"ref": "RE", "libelle": "Autres achats", "compte": "604, 605, 608", "type": "charge", "is_total": false},
|
| 19 |
+
{"ref": "RF", "libelle": "Variation de stocks d'autres approvisionnements", "compte": "6033", "type": "mixte", "is_total": false},
|
| 20 |
+
{"ref": "RG", "libelle": "Transports", "compte": "61", "type": "charge", "is_total": false},
|
| 21 |
+
{"ref": "RH", "libelle": "Services extérieurs", "compte": "62, 63", "type": "charge", "is_total": false},
|
| 22 |
+
{"ref": "RI", "libelle": "Impôts et taxes", "compte": "64", "type": "charge", "is_total": false},
|
| 23 |
+
{"ref": "RJ", "libelle": "Autres charges", "compte": "65", "type": "charge", "is_total": false},
|
| 24 |
+
{"ref": "XC", "libelle": "VALEUR AJOUTEE (XB + RA + RB) + (somme TE à RJ)", "formula": "XB-RA+RB+TE+TF+TG+TH+TI-RC+RD-RE+RF-RG-RH-RI-RJ", "is_total": true, "bg_color": "#DDEBF7"},
|
| 25 |
+
{"ref": "RK", "libelle": "Charges de personnel", "compte": "66", "type": "charge", "is_total": false},
|
| 26 |
+
{"ref": "XD", "libelle": "EXCEDENT BRUT D'EXPLOITATION (XC + RK)", "formula": "XC-RK", "is_total": true, "bg_color": "#DDEBF7"},
|
| 27 |
+
{"ref": "TJ", "libelle": "Reprises d'amortissements, de provisions et dépréciations", "compte": "791, 798, 799", "type": "produit", "is_total": false},
|
| 28 |
+
{"ref": "RL", "libelle": "Dotations aux amortissements, aux provisions et dépréciations", "compte": "681, 691", "type": "charge", "is_total": false},
|
| 29 |
+
{"ref": "XE", "libelle": "RESULTAT D'EXPLOITATION (XD + TJ + RL)", "formula": "XD+TJ-RL", "is_total": true, "bg_color": "#DDEBF7"},
|
| 30 |
+
{"ref": "TK", "libelle": "Revenus financiers et assimilés", "compte": "77", "type": "produit", "is_total": false},
|
| 31 |
+
{"ref": "TL", "libelle": "Reprises de provisions et dépréciations financières", "compte": "797", "type": "produit", "is_total": false},
|
| 32 |
+
{"ref": "TM", "libelle": "Transferts de charges financières", "compte": "787", "type": "produit", "is_total": false},
|
| 33 |
+
{"ref": "RM", "libelle": "Frais financiers et charges assimilés", "compte": "67", "type": "charge", "is_total": false},
|
| 34 |
+
{"ref": "RN", "libelle": "Dotations aux provisions et aux dépréciations financières", "compte": "697", "type": "charge", "is_total": false},
|
| 35 |
+
{"ref": "XF", "libelle": "RESULTAT FINANCIER (somme TK à RN)", "formula": "TK+TL+TM-RM-RN", "is_total": true, "bg_color": "#DDEBF7"},
|
| 36 |
+
{"ref": "XG", "libelle": "RESULTAT DES ACTIVITES ORDINAIRES (XE + XF)", "formula": "XE+XF", "is_total": true, "bg_color": "#FCE4D6"},
|
| 37 |
+
{"ref": "TN", "libelle": "Produits des cessions d'immobilisations", "compte": "82", "type": "produit", "is_total": false},
|
| 38 |
+
{"ref": "TO", "libelle": "Autres Produits HAO", "compte": "84, 86, 88", "type": "produit", "is_total": false},
|
| 39 |
+
{"ref": "RO", "libelle": "Valeurs comptables des cessions d'immobilisations", "compte": "81", "type": "charge", "is_total": false},
|
| 40 |
+
{"ref": "RP", "libelle": "Autres Charges HAO", "compte": "83, 85", "type": "charge", "is_total": false},
|
| 41 |
+
{"ref": "XH", "libelle": "RESULTAT HORS ACTIVITES ORDINAIRES (somme TN à RP)", "formula": "TN+TO-RO-RP", "is_total": true, "bg_color": "#DDEBF7"},
|
| 42 |
+
{"ref": "RQ", "libelle": "Participation des travailleurs", "compte": "87", "type": "charge", "is_total": false},
|
| 43 |
+
{"ref": "RS", "libelle": "Impôts sur le résultat", "compte": "89", "type": "charge", "is_total": false},
|
| 44 |
+
{"ref": "XI", "libelle": "RESULTAT NET (XG + XH + RQ + RS)", "formula": "XG+XH-RQ-RS", "is_total": true, "bg_color": "#E2EFDA"}
|
| 45 |
+
]
|
| 46 |
+
}
|
| 47 |
+
|
api/tests_new.py
CHANGED
|
@@ -3,6 +3,8 @@ from rest_framework.test import APITestCase, APIClient
|
|
| 3 |
from rest_framework import status
|
| 4 |
from django.contrib.auth import get_user_model
|
| 5 |
from .models import Notification, SupportTicket
|
|
|
|
|
|
|
| 6 |
|
| 7 |
User = get_user_model()
|
| 8 |
|
|
@@ -113,3 +115,38 @@ class SupportTicketTests(APITestCase):
|
|
| 113 |
response = self.client.get(self.support_url)
|
| 114 |
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 115 |
self.assertEqual(len(response.data['results']), 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from rest_framework import status
|
| 4 |
from django.contrib.auth import get_user_model
|
| 5 |
from .models import Notification, SupportTicket
|
| 6 |
+
import zipfile
|
| 7 |
+
import io
|
| 8 |
|
| 9 |
User = get_user_model()
|
| 10 |
|
|
|
|
| 115 |
response = self.client.get(self.support_url)
|
| 116 |
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 117 |
self.assertEqual(len(response.data['results']), 1)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class SyscohadaReportsTests(APITestCase):
|
| 121 |
+
"""Tests pour l'export SYSCOHADA (ZIP)"""
|
| 122 |
+
|
| 123 |
+
def setUp(self):
|
| 124 |
+
self.user = User.objects.create_user(
|
| 125 |
+
email='syscohada@example.com',
|
| 126 |
+
password='TestPass123!',
|
| 127 |
+
first_name='John',
|
| 128 |
+
last_name='Doe'
|
| 129 |
+
)
|
| 130 |
+
self.client = APIClient()
|
| 131 |
+
self.client.force_authenticate(user=self.user)
|
| 132 |
+
self.url = reverse('syscohada-download')
|
| 133 |
+
|
| 134 |
+
def test_download_zip_contains_two_files(self):
|
| 135 |
+
response = self.client.get(self.url, {'year': 2026})
|
| 136 |
+
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
| 137 |
+
self.assertEqual(response['Content-Type'], 'application/zip')
|
| 138 |
+
|
| 139 |
+
zf = zipfile.ZipFile(io.BytesIO(response.content))
|
| 140 |
+
names = sorted(zf.namelist())
|
| 141 |
+
self.assertEqual(len(names), 2)
|
| 142 |
+
self.assertTrue(any(name.startswith('compte_resultat_syscohada_2026') for name in names))
|
| 143 |
+
self.assertTrue(any(name.startswith('bilan_syscohada_2026') for name in names))
|
| 144 |
+
|
| 145 |
+
# Basic content sanity
|
| 146 |
+
cr_name = next(name for name in names if name.startswith('compte_resultat_syscohada_2026'))
|
| 147 |
+
cr_csv = zf.read(cr_name).decode('utf-8')
|
| 148 |
+
self.assertIn('REF,LIBELLES,NUMERO DE COMPTES,MONTANT_N,MONTANT_N_1', cr_csv)
|
| 149 |
+
|
| 150 |
+
bilan_name = next(name for name in names if name.startswith('bilan_syscohada_2026'))
|
| 151 |
+
bilan_csv = zf.read(bilan_name).decode('utf-8')
|
| 152 |
+
self.assertIn('SECTION,REF,LIBELLE,NOTE,BRUT,AMORT/DEPREC,NET_N,NET_N_1', bilan_csv)
|
api/urls.py
CHANGED
|
@@ -6,6 +6,7 @@ from .views import (
|
|
| 6 |
RegisterView, LoginView, ProfileView, ChangePasswordView,
|
| 7 |
ProductViewSet, TransactionViewSet, BudgetViewSet, AdViewSet,
|
| 8 |
NotificationViewSet, SupportTicketViewSet, VoiceCommandView, AIInsightsView,
|
|
|
|
| 9 |
analytics_overview, analytics_breakdown, analytics_kpi, analytics_activity,
|
| 10 |
analytics_balance_history
|
| 11 |
)
|
|
@@ -40,4 +41,7 @@ urlpatterns = [
|
|
| 40 |
# ===== VOICE AI =====
|
| 41 |
path('voice-command/', VoiceCommandView.as_view(), name='voice-command'),
|
| 42 |
path('ai-insights/', AIInsightsView.as_view(), name='ai-insights'),
|
|
|
|
|
|
|
|
|
|
| 43 |
]
|
|
|
|
| 6 |
RegisterView, LoginView, ProfileView, ChangePasswordView,
|
| 7 |
ProductViewSet, TransactionViewSet, BudgetViewSet, AdViewSet,
|
| 8 |
NotificationViewSet, SupportTicketViewSet, VoiceCommandView, AIInsightsView,
|
| 9 |
+
SyscohadaReportsDownloadView,
|
| 10 |
analytics_overview, analytics_breakdown, analytics_kpi, analytics_activity,
|
| 11 |
analytics_balance_history
|
| 12 |
)
|
|
|
|
| 41 |
# ===== VOICE AI =====
|
| 42 |
path('voice-command/', VoiceCommandView.as_view(), name='voice-command'),
|
| 43 |
path('ai-insights/', AIInsightsView.as_view(), name='ai-insights'),
|
| 44 |
+
|
| 45 |
+
# ===== REPORTS (SYSCOHADA) =====
|
| 46 |
+
path('reports/syscohada/download/', SyscohadaReportsDownloadView.as_view(), name='syscohada-download'),
|
| 47 |
]
|
api/views.py
CHANGED
|
@@ -29,6 +29,8 @@ from .groq_service import GroqService
|
|
| 29 |
from .assemblyai_service import AssemblyAIService
|
| 30 |
import tempfile
|
| 31 |
import os
|
|
|
|
|
|
|
| 32 |
|
| 33 |
User = get_user_model()
|
| 34 |
|
|
@@ -836,4 +838,49 @@ class AIInsightsView(APIView):
|
|
| 836 |
if last_insight:
|
| 837 |
return Response({'insights': last_insight.content, 'cached': True, 'error_fallback': str(e)})
|
| 838 |
|
| 839 |
-
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
from .assemblyai_service import AssemblyAIService
|
| 30 |
import tempfile
|
| 31 |
import os
|
| 32 |
+
import io
|
| 33 |
+
import zipfile
|
| 34 |
|
| 35 |
User = get_user_model()
|
| 36 |
|
|
|
|
| 838 |
if last_insight:
|
| 839 |
return Response({'insights': last_insight.content, 'cached': True, 'error_fallback': str(e)})
|
| 840 |
|
| 841 |
+
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
class SyscohadaReportsDownloadView(APIView):
|
| 845 |
+
"""
|
| 846 |
+
Téléchargement en 1 clic des 2 rapports SYSCOHADA (MVP):
|
| 847 |
+
- Compte de résultat (CSV)
|
| 848 |
+
- Bilan (CSV)
|
| 849 |
+
|
| 850 |
+
Réponse: un ZIP contenant exactement 2 fichiers.
|
| 851 |
+
"""
|
| 852 |
+
|
| 853 |
+
permission_classes = [IsAuthenticated]
|
| 854 |
+
|
| 855 |
+
def get(self, request):
|
| 856 |
+
from .syscohada_reports import (
|
| 857 |
+
compute_compte_resultat,
|
| 858 |
+
generate_bilan_csv,
|
| 859 |
+
generate_compte_resultat_csv,
|
| 860 |
+
)
|
| 861 |
+
|
| 862 |
+
try:
|
| 863 |
+
year = int(request.query_params.get("year") or timezone.now().year)
|
| 864 |
+
except ValueError:
|
| 865 |
+
return Response(
|
| 866 |
+
{"type": "validation_error", "errors": {"year": ["Invalid year."]}},
|
| 867 |
+
status=status.HTTP_400_BAD_REQUEST,
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
compte = compute_compte_resultat(request.user, year)
|
| 871 |
+
compte_csv = generate_compte_resultat_csv(compte)
|
| 872 |
+
bilan_csv = generate_bilan_csv(request.user, compte)
|
| 873 |
+
|
| 874 |
+
zip_buf = io.BytesIO()
|
| 875 |
+
zip_name = f"rapports_syscohada_{year}.zip"
|
| 876 |
+
cr_name = f"compte_resultat_syscohada_{year}.csv"
|
| 877 |
+
bilan_name = f"bilan_syscohada_{year}.csv"
|
| 878 |
+
|
| 879 |
+
with zipfile.ZipFile(zip_buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
| 880 |
+
zf.writestr(cr_name, compte_csv)
|
| 881 |
+
zf.writestr(bilan_name, bilan_csv)
|
| 882 |
+
|
| 883 |
+
zip_buf.seek(0)
|
| 884 |
+
response = HttpResponse(zip_buf.getvalue(), content_type="application/zip")
|
| 885 |
+
response["Content-Disposition"] = f'attachment; filename="{zip_name}"'
|
| 886 |
+
return response
|