rinogeek commited on
Commit
9d855fa
·
1 Parent(s): 905e029
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=bool)
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