rinogeek commited on
Commit
833c388
·
1 Parent(s): 9d855fa

fix: correct transaction dates (2024 clock drift); improve validate_date; add fix_dates command

Browse files
Akompta/settings.py CHANGED
@@ -13,7 +13,6 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
13
  import os
14
  from pathlib import Path
15
  from datetime import timedelta
16
- from decouple import config, Csv
17
 
18
  # Build paths inside the project like this: BASE_DIR / 'subdir'.
19
  BASE_DIR = Path(__file__).resolve().parent.parent
@@ -23,7 +22,20 @@ BASE_DIR = Path(__file__).resolve().parent.parent
23
  # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
24
 
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):
@@ -35,15 +47,16 @@ def _parse_bool(value):
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
 
42
  # CSRF Trusted Origins for Hugging Face and Frontend
43
- CSRF_TRUSTED_ORIGINS = config(
44
- 'CSRF_TRUSTED_ORIGINS',
45
- default='https://*.hf.space,https://*.huggingface.co,https://akompta-ai-flame.vercel.app,https://cosmolabhub-akomptabackend.hf.space',
46
- cast=Csv()
 
47
  )
48
 
49
 
@@ -215,10 +228,11 @@ SIMPLE_JWT = {
215
 
216
 
217
  # CORS Configuration
218
- CORS_ALLOWED_ORIGINS = config(
219
- 'CORS_ALLOWED_ORIGINS',
220
- default='http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173,https://akompta-ai-flame.vercel.app,https://cosmolabhub-akomptabackend.hf.space',
221
- cast=Csv()
 
222
  )
223
 
224
  CORS_ALLOW_CREDENTIALS = True
 
13
  import os
14
  from pathlib import Path
15
  from datetime import timedelta
 
16
 
17
  # Build paths inside the project like this: BASE_DIR / 'subdir'.
18
  BASE_DIR = Path(__file__).resolve().parent.parent
 
22
  # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
23
 
24
  # SECURITY WARNING: keep the secret key used in production secret!
25
+ def _env(name: str, default: str | None = None) -> str | None:
26
+ value = os.environ.get(name)
27
+ if value is None or value == "":
28
+ return default
29
+ return value
30
+
31
+
32
+ def _parse_csv(value: str | None) -> list[str]:
33
+ if not value:
34
+ return []
35
+ return [item.strip() for item in value.split(",") if item.strip()]
36
+
37
+
38
+ SECRET_KEY = _env("SECRET_KEY", "django-insecure-3m1!a3u-z=5k8x9y#-954&3ree&mr&$o97fuy8ds*8dox!(rvx")
39
 
40
  # decouple's built-in bool cast is strict and can crash on values like "release".
41
  def _parse_bool(value):
 
47
  return False
48
 
49
  # SECURITY WARNING: don't run with debug turned on in production!
50
+ DEBUG = _parse_bool(_env("DEBUG", "True"))
51
 
52
+ ALLOWED_HOSTS = _parse_csv(_env("ALLOWED_HOSTS", "*"))
53
 
54
  # CSRF Trusted Origins for Hugging Face and Frontend
55
+ CSRF_TRUSTED_ORIGINS = _parse_csv(
56
+ _env(
57
+ "CSRF_TRUSTED_ORIGINS",
58
+ "https://*.hf.space,https://*.huggingface.co,https://akompta-ai-flame.vercel.app,https://cosmolabhub-akomptabackend.hf.space",
59
+ )
60
  )
61
 
62
 
 
228
 
229
 
230
  # CORS Configuration
231
+ CORS_ALLOWED_ORIGINS = _parse_csv(
232
+ _env(
233
+ "CORS_ALLOWED_ORIGINS",
234
+ "http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173,https://akompta-ai-flame.vercel.app,https://cosmolabhub-akomptabackend.hf.space",
235
+ )
236
  )
237
 
238
  CORS_ALLOW_CREDENTIALS = True
Documentation/audit_inputs/bilan_syscohada_2026_ginni.csv ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SECTION,REF,LIBELLE,NOTE,BRUT,AMORT/DEPREC,NET_N,NET_N_1
2
+ ACTIF,AD,IMMOBILISATIONS INCORPORELLES,3,0,0,0,0
3
+ ACTIF,AE,Frais de développement et de prospection,,0,0,0,0
4
+ ACTIF,AF,"Brevets, licences, logiciels, et droits similaires",,0,0,0,0
5
+ ACTIF,AG,Fonds commercial et droit au bail,,0,0,0,0
6
+ ACTIF,AH,Autres immobilisations incorporelles,,0,0,0,0
7
+ ACTIF,AI,IMMOBILISATIONS CORPORELLES,3,0,0,0,0
8
+ ACTIF,AJ,Terrains (1),,0,0,0,0
9
+ ACTIF,AK,Bâtiments (1),,0,0,0,0
10
+ ACTIF,AL,"Aménagements, agencements et installations",,0,0,0,0
11
+ ACTIF,AM,"Matériel, mobilier et actifs biologiques",,0,0,0,0
12
+ ACTIF,AN,Matériel de transport,,0,0,0,0
13
+ ACTIF,AP,Avances et acomptes versés sur immobilisations,3,0,0,0,0
14
+ ACTIF,AQ,IMMOBILISATIONS FINANCIERES,4,0,0,0,0
15
+ ACTIF,AR,Titres de participation,,0,0,0,0
16
+ ACTIF,AS,Autres immobilisations financières,,0,0,0,0
17
+ ACTIF,AZ,TOTAL ACTIF IMMOBILISE,,0,0,0,0
18
+ ACTIF,BA,ACTIF CIRCULANT HAO,5,0,0,0,0
19
+ ACTIF,BB,STOCKS ET ENCOURS,6,0,0,0,0
20
+ ACTIF,BG,CREANCES ET EMPLOIS ASSIMILES,,0,0,0,0
21
+ ACTIF,BH,Fournisseurs avances versées,17,0,0,0,0
22
+ ACTIF,BI,Clients,7,0,0,0,0
23
+ ACTIF,BJ,Autres créances,8,0,0,0,0
24
+ ACTIF,BK,TOTAL ACTIF CIRCULANT,,0,0,0,0
25
+ ACTIF,BQ,Titres de placement,9,0,0,0,0
26
+ ACTIF,BR,Valeurs à encaisser,10,0,0,0,0
27
+ ACTIF,BS,"Banques, chèques postaux, caisse et assimilés",11,0.00,0,0.00,0.00
28
+ ACTIF,BT,TOTAL TRESORERIE-ACTIF,,0.00,0,0.00,0.00
29
+ ACTIF,BU,Ecart de conversion-Actif,12,0,0,0,0
30
+ ACTIF,BZ,TOTAL GENERAL,,0.00,0,0.00,0.00
31
+ PASSIF,CA,Capital,13,,,0,0
32
+ PASSIF,CB,Apporteurs capital non appelé (-),13,,,0,0
33
+ PASSIF,CD,Primes liées au capital social,14,,,0,0
34
+ PASSIF,CE,Ecarts de réévaluation,3e,,,0,0
35
+ PASSIF,CF,Réserves indisponibles,14,,,0,0
36
+ PASSIF,CG,Réserves libres,14,,,0,0
37
+ PASSIF,CH,Report à nouveau (+ ou -),14,,,0,0
38
+ PASSIF,CJ,Résultat net de l'exercice (bénéfice + ou perte -),,,,0,0
39
+ PASSIF,CL,Subventions d'investissement,15,,,0,0
40
+ PASSIF,CM,Provisions réglementées,15,,,0,0
41
+ PASSIF,CP,TOTAL CAPITAUX PROPRES ET RESSOURCES ASSIMILEES,,,,0,0
42
+ PASSIF,DA,Emprunts et dettes financières diverses,16,,,0,0
43
+ PASSIF,DB,Dettes de location acquisition,16,,,0,0
44
+ PASSIF,DC,Provisions pour risques et charges,16,,,0,0
45
+ PASSIF,DD,TOTAL DETTES FINANCIERES ET RESSOURCES ASSIMILEES,,,,0,0
46
+ PASSIF,DF,TOTAL RESSOURCES STABLES,,,,0,0
47
+ PASSIF,DH,Dettes circulantes HAO,5,,,0,0
48
+ PASSIF,DI,"Clients, avances reçues",7,,,0,0
49
+ PASSIF,DJ,Fournisseurs d'exploitation,17,,,0,0
50
+ PASSIF,DK,Dettes fiscales et sociales,18,,,0,0
51
+ PASSIF,DM,Autres dettes,19,,,0,0
52
+ PASSIF,DN,Provisions pour risques à court terme,19,,,0,0
53
+ PASSIF,DP,TOTAL PASSIF CIRCULANT,,,,0,0
54
+ PASSIF,DQ,"Banques, crédits d'escompte",20,,,0,0
55
+ PASSIF,DR,"Banques, établissements financiers et crédits de trésorerie",20,,,0,0
56
+ PASSIF,DT,TOTAL TRESORERIE-PASSIF,,,,0,0
57
+ PASSIF,DV,Ecart de conversion-Passif,12,,,0,0
58
+ PASSIF,DZ,TOTAL GENERAL,,,,0,0
59
+
Documentation/audit_inputs/compte_resultat_syscohada_2026_ginni.csv ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ REF,LIBELLES,NUMERO DE COMPTES,MONTANT_N,MONTANT_N_1
2
+ TA,Ventes de marchandises,701,0,0
3
+ RA,Achats de marchandises,601,0,0
4
+ RB,Variation de stocks de marchandises,6031,0,0
5
+ XA,MARGE COMMERCIALE (Somme TA à RB),,0,0
6
+ TB,Ventes de produits fabriqués,"702, 703, 704",0,0
7
+ TC,"Travaux, services vendus","705, 706",0,0
8
+ TD,Produits accessoires,707,0,0
9
+ XB,CHIFFRE D'AFFAIRES (A + B + C + D),,0,0
10
+ TE,Production stockée (ou déstockage),73,0,0
11
+ TF,Production immobilisée,72,0,0
12
+ TG,Subventions d'exploitation,71,0,0
13
+ TH,Autres produits,75,0,0
14
+ TI,Transferts de charges d'exploitation,781,0,0
15
+ RC,Achats de matières premières et fournitures liées,602,0,0
16
+ RD,Variation de stocks de matières premières et fournitures liées,6032,0,0
17
+ RE,Autres achats,"604, 605, 608",0,0
18
+ RF,Variation de stocks d'autres approvisionnements,6033,0,0
19
+ RG,Transports,61,0,0
20
+ RH,Services extérieurs,"62, 63",0,0
21
+ RI,Impôts et taxes,64,0,0
22
+ RJ,Autres charges,65,0,0
23
+ XC,VALEUR AJOUTEE (XB + RA + RB) + (somme TE à RJ),,0,0
24
+ RK,Charges de personnel,66,0,0
25
+ XD,EXCEDENT BRUT D'EXPLOITATION (XC + RK),,0,0
26
+ TJ,"Reprises d'amortissements, de provisions et dépréciations","791, 798, 799",0,0
27
+ RL,"Dotations aux amortissements, aux provisions et dépréciations","681, 691",0,0
28
+ XE,RESULTAT D'EXPLOITATION (XD + TJ + RL),,0,0
29
+ TK,Revenus financiers et assimilés,77,0,0
30
+ TL,Reprises de provisions et dépréciations financières,797,0,0
31
+ TM,Transferts de charges financières,787,0,0
32
+ RM,Frais financiers et charges assimilés,67,0,0
33
+ RN,Dotations aux provisions et aux dépréciations financières,697,0,0
34
+ XF,RESULTAT FINANCIER (somme TK à RN),,0,0
35
+ XG,RESULTAT DES ACTIVITES ORDINAIRES (XE + XF),,0,0
36
+ TN,Produits des cessions d'immobilisations,82,0,0
37
+ TO,Autres Produits HAO,"84, 86, 88",0,0
38
+ RO,Valeurs comptables des cessions d'immobilisations,81,0,0
39
+ RP,Autres Charges HAO,"83, 85",0,0
40
+ XH,RESULTAT HORS ACTIVITES ORDINAIRES (somme TN à RP),,0,0
41
+ RQ,Participation des travailleurs,87,0,0
42
+ RS,Impôts sur le résultat,89,0,0
43
+ XI,RESULTAT NET (XG + XH + RQ + RS),,0,0
44
+
Documentation/demo_exports/bilan_syscohada_2026_demo.csv ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SECTION,REF,LIBELLE,NOTE,BRUT,AMORT/DEPREC,NET_N,NET_N_1
2
+ ACTIF,AD,IMMOBILISATIONS INCORPORELLES,3,0,0,0,0
3
+ ACTIF,AE,Frais de développement et de prospection,,0,0,0,0
4
+ ACTIF,AF,"Brevets, licences, logiciels, et droits similaires",,0,0,0,0
5
+ ACTIF,AG,Fonds commercial et droit au bail,,0,0,0,0
6
+ ACTIF,AH,Autres immobilisations incorporelles,,0,0,0,0
7
+ ACTIF,AI,IMMOBILISATIONS CORPORELLES,3,0,0,0,0
8
+ ACTIF,AJ,Terrains (1),,0,0,0,0
9
+ ACTIF,AK,Bâtiments (1),,0,0,0,0
10
+ ACTIF,AL,"Aménagements, agencements et installations",,0,0,0,0
11
+ ACTIF,AM,"Matériel, mobilier et actifs biologiques",,0,0,0,0
12
+ ACTIF,AN,Matériel de transport,,0,0,0,0
13
+ ACTIF,AP,Avances et acomptes versés sur immobilisations,3,0,0,0,0
14
+ ACTIF,AQ,IMMOBILISATIONS FINANCIERES,4,0,0,0,0
15
+ ACTIF,AR,Titres de participation,,0,0,0,0
16
+ ACTIF,AS,Autres immobilisations financières,,0,0,0,0
17
+ ACTIF,AZ,TOTAL ACTIF IMMOBILISE,,0,0,0,0
18
+ ACTIF,BA,ACTIF CIRCULANT HAO,5,0,0,0,0
19
+ ACTIF,BB,STOCKS ET ENCOURS,6,0,0,0,0
20
+ ACTIF,BG,CREANCES ET EMPLOIS ASSIMILES,,0,0,0,0
21
+ ACTIF,BH,Fournisseurs avances versées,17,0,0,0,0
22
+ ACTIF,BI,Clients,7,0,0,0,0
23
+ ACTIF,BJ,Autres créances,8,0,0,0,0
24
+ ACTIF,BK,TOTAL ACTIF CIRCULANT,,0,0,0,0
25
+ ACTIF,BQ,Titres de placement,9,0,0,0,0
26
+ ACTIF,BR,Valeurs à encaisser,10,0,0,0,0
27
+ ACTIF,BS,"Banques, chèques postaux, caisse et assimilés",11,132266.00,0,132266.00,100000.00
28
+ ACTIF,BT,TOTAL TRESORERIE-ACTIF,,132266.00,0,132266.00,100000.00
29
+ ACTIF,BU,Ecart de conversion-Actif,12,0,0,0,0
30
+ ACTIF,BZ,TOTAL GENERAL,,132266.00,0,132266.00,100000.00
31
+ PASSIF,CA,Capital,13,,,100000.00,0
32
+ PASSIF,CB,Apporteurs capital non appelé (-),13,,,0,0
33
+ PASSIF,CD,Primes liées au capital social,14,,,0,0
34
+ PASSIF,CE,Ecarts de réévaluation,3e,,,0,0
35
+ PASSIF,CF,Réserves indisponibles,14,,,0,0
36
+ PASSIF,CG,Réserves libres,14,,,0,0
37
+ PASSIF,CH,Report à nouveau (+ ou -),14,,,0,0
38
+ PASSIF,CJ,Résultat net de l'exercice (bénéfice + ou perte -),,,,32266.00,0
39
+ PASSIF,CL,Subventions d'investissement,15,,,0,0
40
+ PASSIF,CM,Provisions réglementées,15,,,0,0
41
+ PASSIF,CP,TOTAL CAPITAUX PROPRES ET RESSOURCES ASSIMILEES,,,,132266.00,0
42
+ PASSIF,DA,Emprunts et dettes financières diverses,16,,,0,0
43
+ PASSIF,DB,Dettes de location acquisition,16,,,0,0
44
+ PASSIF,DC,Provisions pour risques et charges,16,,,0,0
45
+ PASSIF,DD,TOTAL DETTES FINANCIERES ET RESSOURCES ASSIMILEES,,,,0,0
46
+ PASSIF,DF,TOTAL RESSOURCES STABLES,,,,132266.00,0
47
+ PASSIF,DH,Dettes circulantes HAO,5,,,0,0
48
+ PASSIF,DI,"Clients, avances reçues",7,,,0,0
49
+ PASSIF,DJ,Fournisseurs d'exploitation,17,,,0,0
50
+ PASSIF,DK,Dettes fiscales et sociales,18,,,0,0
51
+ PASSIF,DM,Autres dettes,19,,,0,0
52
+ PASSIF,DN,Provisions pour risques à court terme,19,,,0,0
53
+ PASSIF,DP,TOTAL PASSIF CIRCULANT,,,,0,0
54
+ PASSIF,DQ,"Banques, crédits d'escompte",20,,,0,0
55
+ PASSIF,DR,"Banques, établissements financiers et crédits de trésorerie",20,,,0,0
56
+ PASSIF,DT,TOTAL TRESORERIE-PASSIF,,,,0,0
57
+ PASSIF,DV,Ecart de conversion-Passif,12,,,0,0
58
+ PASSIF,DZ,TOTAL GENERAL,,,,132266.00,0
Documentation/demo_exports/bilan_syscohada_2026_ginni_recalc.csv ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SECTION,REF,LIBELLE,NOTE,BRUT,AMORT/DEPREC,NET_N,NET_N_1
2
+ ACTIF,AD,IMMOBILISATIONS INCORPORELLES,3,0,0,0,0
3
+ ACTIF,AE,Frais de développement et de prospection,,0,0,0,0
4
+ ACTIF,AF,"Brevets, licences, logiciels, et droits similaires",,0,0,0,0
5
+ ACTIF,AG,Fonds commercial et droit au bail,,0,0,0,0
6
+ ACTIF,AH,Autres immobilisations incorporelles,,0,0,0,0
7
+ ACTIF,AI,IMMOBILISATIONS CORPORELLES,3,0,0,0,0
8
+ ACTIF,AJ,Terrains (1),,0,0,0,0
9
+ ACTIF,AK,Bâtiments (1),,0,0,0,0
10
+ ACTIF,AL,"Aménagements, agencements et installations",,0,0,0,0
11
+ ACTIF,AM,"Matériel, mobilier et actifs biologiques",,0,0,0,0
12
+ ACTIF,AN,Matériel de transport,,0,0,0,0
13
+ ACTIF,AP,Avances et acomptes versés sur immobilisations,3,0,0,0,0
14
+ ACTIF,AQ,IMMOBILISATIONS FINANCIERES,4,0,0,0,0
15
+ ACTIF,AR,Titres de participation,,0,0,0,0
16
+ ACTIF,AS,Autres immobilisations financières,,0,0,0,0
17
+ ACTIF,AZ,TOTAL ACTIF IMMOBILISE,,0,0,0,0
18
+ ACTIF,BA,ACTIF CIRCULANT HAO,5,0,0,0,0
19
+ ACTIF,BB,STOCKS ET ENCOURS,6,0,0,0,0
20
+ ACTIF,BG,CREANCES ET EMPLOIS ASSIMILES,,0,0,0,0
21
+ ACTIF,BH,Fournisseurs avances versées,17,0,0,0,0
22
+ ACTIF,BI,Clients,7,0,0,0,0
23
+ ACTIF,BJ,Autres créances,8,0,0,0,0
24
+ ACTIF,BK,TOTAL ACTIF CIRCULANT,,0,0,0,0
25
+ ACTIF,BQ,Titres de placement,9,0,0,0,0
26
+ ACTIF,BR,Valeurs à encaisser,10,0,0,0,0
27
+ ACTIF,BS,"Banques, chèques postaux, caisse et assimilés",11,486800.00,0,486800.00,-300000.00
28
+ ACTIF,BT,TOTAL TRESORERIE-ACTIF,,486800.00,0,486800.00,-300000.00
29
+ ACTIF,BU,Ecart de conversion-Actif,12,0,0,0,0
30
+ ACTIF,BZ,TOTAL GENERAL,,486800.00,0,486800.00,-300000.00
31
+ PASSIF,CA,Capital,13,,,0,0
32
+ PASSIF,CB,Apporteurs capital non appelé (-),13,,,0,0
33
+ PASSIF,CD,Primes liées au capital social,14,,,0,0
34
+ PASSIF,CE,Ecarts de réévaluation,3e,,,0,0
35
+ PASSIF,CF,Réserves indisponibles,14,,,0,0
36
+ PASSIF,CG,Réserves libres,14,,,0,0
37
+ PASSIF,CH,Report à nouveau (+ ou -),14,,,0,0
38
+ PASSIF,CJ,Résultat net de l'exercice (bénéfice + ou perte -),,,,486800.00,-300000.00
39
+ PASSIF,CL,Subventions d'investissement,15,,,0,0
40
+ PASSIF,CM,Provisions réglementées,15,,,0,0
41
+ PASSIF,CP,TOTAL CAPITAUX PROPRES ET RESSOURCES ASSIMILEES,,,,486800.00,-300000.00
42
+ PASSIF,DA,Emprunts et dettes financières diverses,16,,,0,0
43
+ PASSIF,DB,Dettes de location acquisition,16,,,0,0
44
+ PASSIF,DC,Provisions pour risques et charges,16,,,0,0
45
+ PASSIF,DD,TOTAL DETTES FINANCIERES ET RESSOURCES ASSIMILEES,,,,0,0
46
+ PASSIF,DF,TOTAL RESSOURCES STABLES,,,,486800.00,-300000.00
47
+ PASSIF,DH,Dettes circulantes HAO,5,,,0,0
48
+ PASSIF,DI,"Clients, avances reçues",7,,,0,0
49
+ PASSIF,DJ,Fournisseurs d'exploitation,17,,,0,0
50
+ PASSIF,DK,Dettes fiscales et sociales,18,,,0,0
51
+ PASSIF,DM,Autres dettes,19,,,0,0
52
+ PASSIF,DN,Provisions pour risques à court terme,19,,,0,0
53
+ PASSIF,DP,TOTAL PASSIF CIRCULANT,,,,0,0
54
+ PASSIF,DQ,"Banques, crédits d'escompte",20,,,0,0
55
+ PASSIF,DR,"Banques, établissements financiers et crédits de trésorerie",20,,,0,0
56
+ PASSIF,DT,TOTAL TRESORERIE-PASSIF,,,,0,0
57
+ PASSIF,DV,Ecart de conversion-Passif,12,,,0,0
58
+ PASSIF,DZ,TOTAL GENERAL,,,,486800.00,-300000.00
Documentation/demo_exports/compte_resultat_syscohada_2026_demo.csv ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ REF,LIBELLES,NUMERO DE COMPTES,MONTANT_N,MONTANT_N_1
2
+ TA,Ventes de marchandises,701,25000.00,0
3
+ RA,Achats de marchandises,601,30000.00,0
4
+ RB,Variation de stocks de marchandises,6031,0,0
5
+ XA,MARGE COMMERCIALE (Somme TA à RB),,-5000.00,0
6
+ TB,Ventes de produits fabriqués,"702, 703, 704",0,0
7
+ TC,"Travaux, services vendus","705, 706",80000.00,0
8
+ TD,Produits accessoires,707,12000.00,0
9
+ XB,CHIFFRE D'AFFAIRES (A + B + C + D),,117000.00,0
10
+ TE,Production stockée (ou déstockage),73,0,0
11
+ TF,Production immobilisée,72,0,0
12
+ TG,Subventions d'exploitation,71,0,0
13
+ TH,Autres produits,75,0,0
14
+ TI,Transferts de charges d'exploitation,781,0,0
15
+ RC,Achats de matières premières et fournitures liées,602,0,0
16
+ RD,Variation de stocks de matières premières et fournitures liées,6032,0,0
17
+ RE,Autres achats,"604, 605, 608",0,0
18
+ RF,Variation de stocks d'autres approvisionnements,6033,0,0
19
+ RG,Transports,61,3500.00,0
20
+ RH,Services extérieurs,"62, 63",51234.00,0
21
+ RI,Impôts et taxes,64,0,0
22
+ RJ,Autres charges,65,0,0
23
+ XC,VALEUR AJOUTEE (XB + RA + RB) + (somme TE à RJ),,32266.00,0
24
+ RK,Charges de personnel,66,0,0
25
+ XD,EXCEDENT BRUT D'EXPLOITATION (XC + RK),,32266.00,0
26
+ TJ,"Reprises d'amortissements, de provisions et dépréciations","791, 798, 799",0,0
27
+ RL,"Dotations aux amortissements, aux provisions et dépréciations","681, 691",0,0
28
+ XE,RESULTAT D'EXPLOITATION (XD + TJ + RL),,32266.00,0
29
+ TK,Revenus financiers et assimilés,77,0,0
30
+ TL,Reprises de provisions et dépréciations financières,797,0,0
31
+ TM,Transferts de charges financières,787,0,0
32
+ RM,Frais financiers et charges assimilés,67,0,0
33
+ RN,Dotations aux provisions et aux dépréciations financières,697,0,0
34
+ XF,RESULTAT FINANCIER (somme TK à RN),,0,0
35
+ XG,RESULTAT DES ACTIVITES ORDINAIRES (XE + XF),,32266.00,0
36
+ TN,Produits des cessions d'immobilisations,82,0,0
37
+ TO,Autres Produits HAO,"84, 86, 88",0,0
38
+ RO,Valeurs comptables des cessions d'immobilisations,81,0,0
39
+ RP,Autres Charges HAO,"83, 85",0,0
40
+ XH,RESULTAT HORS ACTIVITES ORDINAIRES (somme TN à RP),,0,0
41
+ RQ,Participation des travailleurs,87,0,0
42
+ RS,Impôts sur le résultat,89,0,0
43
+ XI,RESULTAT NET (XG + XH + RQ + RS),,32266.00,0
Documentation/demo_exports/compte_resultat_syscohada_2026_ginni_recalc.csv ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ REF,LIBELLES,NUMERO DE COMPTES,MONTANT_N,MONTANT_N_1
2
+ TA,Ventes de marchandises,701,500000.00,0
3
+ RA,Achats de marchandises,601,12000.00,300000.00
4
+ RB,Variation de stocks de marchandises,6031,0,0
5
+ XA,MARGE COMMERCIALE (Somme TA à RB),,488000.00,-300000.00
6
+ TB,Ventes de produits fabriqués,"702, 703, 704",0,0
7
+ TC,"Travaux, services vendus","705, 706",0,0
8
+ TD,Produits accessoires,707,0,0
9
+ XB,CHIFFRE D'AFFAIRES (A + B + C + D),,500000.00,0
10
+ TE,Production stockée (ou déstockage),73,0,0
11
+ TF,Production immobilisée,72,0,0
12
+ TG,Subventions d'exploitation,71,0,0
13
+ TH,Autres produits,75,0,0
14
+ TI,Transferts de charges d'exploitation,781,0,0
15
+ RC,Achats de matières premières et fournitures liées,602,0,0
16
+ RD,Variation de stocks de matières premières et fournitures liées,6032,0,0
17
+ RE,Autres achats,"604, 605, 608",0,0
18
+ RF,Variation de stocks d'autres approvisionnements,6033,0,0
19
+ RG,Transports,61,1200.00,0
20
+ RH,Services extérieurs,"62, 63",0,0
21
+ RI,Impôts et taxes,64,0,0
22
+ RJ,Autres charges,65,0,0
23
+ XC,VALEUR AJOUTEE (XB + RA + RB) + (somme TE à RJ),,486800.00,-300000.00
24
+ RK,Charges de personnel,66,0,0
25
+ XD,EXCEDENT BRUT D'EXPLOITATION (XC + RK),,486800.00,-300000.00
26
+ TJ,"Reprises d'amortissements, de provisions et dépréciations","791, 798, 799",0,0
27
+ RL,"Dotations aux amortissements, aux provisions et dépréciations","681, 691",0,0
28
+ XE,RESULTAT D'EXPLOITATION (XD + TJ + RL),,486800.00,-300000.00
29
+ TK,Revenus financiers et assimilés,77,0,0
30
+ TL,Reprises de provisions et dépréciations financières,797,0,0
31
+ TM,Transferts de charges financières,787,0,0
32
+ RM,Frais financiers et charges assimilés,67,0,0
33
+ RN,Dotations aux provisions et aux dépréciations financières,697,0,0
34
+ XF,RESULTAT FINANCIER (somme TK à RN),,0,0
35
+ XG,RESULTAT DES ACTIVITES ORDINAIRES (XE + XF),,486800.00,-300000.00
36
+ TN,Produits des cessions d'immobilisations,82,0,0
37
+ TO,Autres Produits HAO,"84, 86, 88",0,0
38
+ RO,Valeurs comptables des cessions d'immobilisations,81,0,0
39
+ RP,Autres Charges HAO,"83, 85",0,0
40
+ XH,RESULTAT HORS ACTIVITES ORDINAIRES (somme TN à RP),,0,0
41
+ RQ,Participation des travailleurs,87,0,0
42
+ RS,Impôts sur le résultat,89,0,0
43
+ XI,RESULTAT NET (XG + XH + RQ + RS),,486800.00,-300000.00
Documentation/syscohada_audit_2026_ginni_at_gmail_com.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SYSCOHADA Audit — ginni@gmail.com — 2026
2
+
3
+ ## Contexte
4
+ - User id: `5`
5
+ - Solde initial (`initial_balance`): `0.00`
6
+ - Transactions non mappées N: `0`
7
+ - Transactions non mappées N-1: `0`
8
+
9
+ ## Sources comparées
10
+ - CR CSV fourni: `backend/Documentation/audit_inputs/compte_resultat_syscohada_2026_ginni.csv`
11
+ - Bilan CSV fourni: `backend/Documentation/audit_inputs/bilan_syscohada_2026_ginni.csv`
12
+
13
+ ## Résultat
14
+ - Statut: **Match** (les exports correspondent aux calculs DB)
15
+
16
+ ## Calculs (DB)
17
+ - Total revenus N: `0`
18
+ - Total dépenses N: `0`
19
+ - Résultat net N (XI): `0`
20
+
21
+ ## Pourquoi tout est à 0 (explication)
22
+
23
+ Pour cet utilisateur et cet exercice, les exports sont à zéro parce que les entrées DB utilisées par le calcul sont nulles :
24
+
25
+ - Exercice N (2026) : **0 transaction** → revenus = `0`, dépenses = `0`
26
+ - Exercice N-1 (2025) : **0 transaction** → revenus = `0`, dépenses = `0`
27
+ - Solde initial (`initial_balance`) = `0.00`
28
+
29
+ Conséquences directes :
30
+
31
+ - Compte de résultat : toutes les lignes alimentées par les transactions (TA, RG, RH, RJ, …) restent à `0`, et les totaux/formules (XA…XI) aboutissent à `0`.
32
+ - Bilan :
33
+ - `BS` (trésorerie) = `initial_balance + revenus - dépenses` = `0.00`
34
+ - `CJ` (résultat net) = `XI` = `0`
35
+ - les autres postes (CA, BI, DJ, …) ne peuvent pas être déduits des transactions Akompta et restent à `0` tant qu’ils ne sont pas saisis via `SyscohadaBilanBalance`.
36
+
37
+ ## Notes
38
+ - Les montants attendus sont calculés via `compute_compte_resultat()` + `generate_bilan_csv()`.
39
+ - Si les exports fournis sont à zéro, vérifier: année des transactions, `initial_balance`, et règles de mapping CR.
Documentation/syscohada_audit_2026_ginni_fixed_dates.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SYSCOHADA Audit — ginni@gmail.com — 2026
2
+
3
+ ## Contexte
4
+ - User id: `5`
5
+ - Solde initial (`initial_balance`): `0.00`
6
+ - Transactions non mappées N: `0`
7
+ - Transactions non mappées N-1: `0`
8
+
9
+ ## Sources comparées
10
+ - CR CSV fourni: `backend/Documentation/demo_exports/compte_resultat_syscohada_2026_ginni_recalc.csv`
11
+ - Bilan CSV fourni: `backend/Documentation/demo_exports/bilan_syscohada_2026_ginni_recalc.csv`
12
+
13
+ ## Résultat
14
+ - Statut: **Match** (les exports correspondent aux calculs DB)
15
+
16
+ ## Calculs (DB)
17
+ - Total revenus N: `500000.00`
18
+ - Total dépenses N: `13200.00`
19
+ - Résultat net N (XI): `486800.00`
20
+
21
+ ## Notes
22
+ - Les montants attendus sont calculés via `compute_compte_resultat()` + `generate_bilan_csv()`.
23
+ - Si les exports fournis sont à zéro, vérifier: année des transactions, `initial_balance`, et règles de mapping CR.
Documentation/syscohada_demo_analysis_2026.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SYSCOHADA Audit — syscohada.demo@gmail.com — 2026
2
+
3
+ ## Contexte
4
+ - User id: `8`
5
+ - Solde initial (`initial_balance`): `100000.00`
6
+ - Transactions non mappées N: `0`
7
+ - Transactions non mappées N-1: `0`
8
+
9
+ ## Sources comparées
10
+ - CR CSV fourni: `backend/Documentation/demo_exports/compte_resultat_syscohada_2026_demo.csv`
11
+ - Bilan CSV fourni: `backend/Documentation/demo_exports/bilan_syscohada_2026_demo.csv`
12
+
13
+ ## Résultat
14
+ - Statut: **Match** (les exports correspondent aux calculs DB)
15
+
16
+ ## Calculs (DB)
17
+ - Total revenus N: `117000.00`
18
+ - Total dépenses N: `84734.00`
19
+ - Résultat net N (XI): `32266.00`
20
+
21
+ ## Notes
22
+ - Les montants attendus sont calculés via `compute_compte_resultat()` + `generate_bilan_csv()`.
23
+ - Si les exports fournis sont à zéro, vérifier: année des transactions, `initial_balance`, et règles de mapping CR.
24
+
25
+ ## Détails Compte de résultat (comment les lignes sont alimentées)
26
+
27
+ Transactions 2026 utilisées:
28
+
29
+ | Date | Type | Catégorie | Libellé | Montant | Ref SYSCOHADA |
30
+ |---|---|---|---|---:|---|
31
+ | 2026-04-02 | income | Accessoire | Produits accessoires | 12000.00 | TD |
32
+ | 2026-05-01 | expense | Loyer | Loyer boutique | 40000.00 | RH |
33
+ | 2026-05-03 | expense | Inconnu | Charge inconnue | 1234.00 | RH |
34
+ | 2026-05-14 | expense | Marketing | Facebook ads | 10000.00 | RH |
35
+ | 2026-05-15 | expense | Achats | Achat marchandises | 30000.00 | RA |
36
+ | 2026-05-16 | income | Service | Prestation conseil | 80000.00 | TC |
37
+ | 2026-05-17 | income | Ventes | Vente tomates | 25000.00 | TA |
38
+ | 2026-05-17 | expense | Transport | Taxi | 3500.00 | RG |
39
+
40
+ Sommes par ref (MONTANT_N):
41
+
42
+ | Ref | Montant |
43
+ |---|---:|
44
+ | TA | 25000.00 |
45
+ | TC | 80000.00 |
46
+ | TD | 12000.00 |
47
+ | RA | 30000.00 |
48
+ | RG | 3500.00 |
49
+ | RH | 51234.00 |
50
+ | RI | 0 |
51
+ | RJ | 0 |
52
+ | XI | 32266.00 |
53
+
54
+ ## Détails Bilan (auto + saisies)
55
+
56
+ - BS (auto, trésorerie) = initial_balance + revenus - dépenses = 100000.00 + 117000.00 - 84734.00 = 132266.00
57
+ - CJ (auto, résultat net) = XI = 32266.00
58
+ - CA (saisi via SyscohadaBilanBalance) = 100000.00 (capital demo)
59
+
60
+ Totaux calculés (validation):
61
+
62
+ - Total Actif (BZ) N = 132266.00
63
+ - Total Passif (DZ) N = 132266.00
64
+ - Delta (Actif - Passif) N = 0.00
api/admin.py CHANGED
@@ -1,7 +1,16 @@
1
  from django.contrib import admin
2
  from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
3
  from django.utils.html import format_html
4
- from .models import User, Product, Transaction, Budget, Ad, AIInsight
 
 
 
 
 
 
 
 
 
5
 
6
 
7
  @admin.register(AIInsight)
@@ -245,4 +254,20 @@ class AdAdmin(admin.ModelAdmin):
245
  # Personnalisation du site admin
246
  admin.site.site_header = "Akompta AI Administration"
247
  admin.site.site_title = "Akompta Admin"
248
- admin.site.index_title = "Bienvenue sur l'administration Akompta"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from django.contrib import admin
2
  from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
3
  from django.utils.html import format_html
4
+ from .models import (
5
+ User,
6
+ Product,
7
+ Transaction,
8
+ Budget,
9
+ Ad,
10
+ AIInsight,
11
+ SyscohadaCRMappingRule,
12
+ SyscohadaBilanBalance,
13
+ )
14
 
15
 
16
  @admin.register(AIInsight)
 
254
  # Personnalisation du site admin
255
  admin.site.site_header = "Akompta AI Administration"
256
  admin.site.site_title = "Akompta Admin"
257
+ admin.site.index_title = "Bienvenue sur l'administration Akompta"
258
+
259
+
260
+ @admin.register(SyscohadaCRMappingRule)
261
+ class SyscohadaCRMappingRuleAdmin(admin.ModelAdmin):
262
+ list_display = ["user", "ref", "tx_type", "match_mode", "priority", "is_active", "updated_at"]
263
+ list_filter = ["is_active", "tx_type", "match_mode", "ref"]
264
+ search_fields = ["user__email", "ref", "category_pattern", "name_pattern"]
265
+ ordering = ["priority", "-updated_at"]
266
+
267
+
268
+ @admin.register(SyscohadaBilanBalance)
269
+ class SyscohadaBilanBalanceAdmin(admin.ModelAdmin):
270
+ list_display = ["user", "year", "section", "ref", "brut", "amort", "net", "updated_at"]
271
+ list_filter = ["year", "section", "ref"]
272
+ search_fields = ["user__email", "ref", "note"]
273
+ ordering = ["-year", "section", "ref"]
api/groq_service.py CHANGED
@@ -1,11 +1,16 @@
1
  import os
2
  import json
3
  from pathlib import Path
4
- from groq import Groq
5
- from django.conf import settings
6
 
7
  class GroqService:
8
  def __init__(self):
 
 
 
 
 
 
9
  # Try to load from environment first
10
  self.api_key = os.environ.get("GROQ_API_KEY")
11
 
@@ -23,8 +28,13 @@ class GroqService:
23
  if not self.api_key or self.api_key == 'your-groq-api-key-here':
24
  # Note: We fallback to 'your-groq-api-key-here' to avoid crashing if it's in .env as a placeholder
25
  print("Warning: GROQ_API_KEY not found or invalid.")
26
-
27
- self.client = Groq(api_key=self.api_key)
 
 
 
 
 
28
  self.model = "whisper-large-v3-turbo"
29
 
30
  def transcribe(self, audio_file, language=None):
@@ -32,6 +42,8 @@ class GroqService:
32
  Transcribe audio file using Groq's Whisper API.
33
  audio_file can be a file-like object or a path.
34
  """
 
 
35
  try:
36
  # Groq expects a file object or a tuple (filename, content, content_type)
37
  # For Django's UploadedFile, passing (file.name, file.read()) works best
@@ -64,6 +76,8 @@ class GroqService:
64
  """
65
  Process text command using Groq's LLM models.
66
  """
 
 
67
  if context_products is None:
68
  context_products = []
69
 
@@ -87,6 +101,11 @@ class GroqService:
87
  Inventory Context (Existing Products):
88
  {json.dumps(context_products)}
89
 
 
 
 
 
 
90
  Return ONLY a JSON object with this EXACT structure:
91
 
92
  If intent is 'create_transaction':
@@ -99,7 +118,7 @@ class GroqService:
99
  "currency": "FCFA",
100
  "category": "Descriptive category",
101
  "name": "Descriptive name",
102
- "date": "YYYY-MM-DD"
103
  }}
104
  }}
105
 
@@ -134,3 +153,43 @@ class GroqService:
134
  except Exception as e:
135
  print(f"Error calling Groq LLM ({model}): {e}")
136
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import json
3
  from pathlib import Path
4
+
 
5
 
6
  class GroqService:
7
  def __init__(self):
8
+ # Import lazy to keep the backend runnable even if the Groq SDK isn't installed
9
+ try:
10
+ from groq import Groq # type: ignore
11
+ except Exception:
12
+ Groq = None # type: ignore
13
+
14
  # Try to load from environment first
15
  self.api_key = os.environ.get("GROQ_API_KEY")
16
 
 
28
  if not self.api_key or self.api_key == 'your-groq-api-key-here':
29
  # Note: We fallback to 'your-groq-api-key-here' to avoid crashing if it's in .env as a placeholder
30
  print("Warning: GROQ_API_KEY not found or invalid.")
31
+
32
+ if Groq is None:
33
+ print("Warning: Groq SDK not installed (pip install groq).")
34
+ self.client = None
35
+ else:
36
+ self.client = Groq(api_key=self.api_key)
37
+
38
  self.model = "whisper-large-v3-turbo"
39
 
40
  def transcribe(self, audio_file, language=None):
 
42
  Transcribe audio file using Groq's Whisper API.
43
  audio_file can be a file-like object or a path.
44
  """
45
+ if not self.client:
46
+ return None
47
  try:
48
  # Groq expects a file object or a tuple (filename, content, content_type)
49
  # For Django's UploadedFile, passing (file.name, file.read()) works best
 
76
  """
77
  Process text command using Groq's LLM models.
78
  """
79
+ if not self.client:
80
+ return None
81
  if context_products is None:
82
  context_products = []
83
 
 
101
  Inventory Context (Existing Products):
102
  {json.dumps(context_products)}
103
 
104
+ IMPORTANT DATE RULE:
105
+ - The model does NOT know today's date and MUST NOT invent dates.
106
+ - Always set "date" to null unless the user explicitly mentions a date.
107
+ - Even if the user does NOT mention a date, do NOT default to any day/month/year.
108
+
109
  Return ONLY a JSON object with this EXACT structure:
110
 
111
  If intent is 'create_transaction':
 
118
  "currency": "FCFA",
119
  "category": "Descriptive category",
120
  "name": "Descriptive name",
121
+ "date": "YYYY-MM-DD" or null
122
  }}
123
  }}
124
 
 
153
  except Exception as e:
154
  print(f"Error calling Groq LLM ({model}): {e}")
155
  return None
156
+
157
+ def process_insights(self, context_data, model="llama-3.1-8b-instant"):
158
+ """
159
+ Génère 3 insights courts (FR) à partir d'un contexte JSON.
160
+ Retourne une liste de 3 strings ou None en cas d'échec.
161
+ """
162
+ if not self.client:
163
+ return None
164
+
165
+ system_prompt = (
166
+ "Tu es un analyste financier expert pour l'application Akompta. "
167
+ "À partir des données JSON (transactions, produits, budgets, etc.), "
168
+ "génère exactement 3 insights courts (1 phrase chacun) en Français:\n"
169
+ "1) Observation sur ventes/revenus\n"
170
+ "2) Observation sur dépenses\n"
171
+ "3) Alerte stock ou recommandation\n"
172
+ "Réponds uniquement en JSON avec la structure: "
173
+ '{ "insights": ["...", "...", "..."] }'
174
+ )
175
+
176
+ try:
177
+ chat_completion = self.client.chat.completions.create(
178
+ messages=[
179
+ {"role": "system", "content": system_prompt},
180
+ {"role": "user", "content": json.dumps(context_data, ensure_ascii=False)},
181
+ ],
182
+ model=model,
183
+ response_format={"type": "json_object"},
184
+ temperature=0.2,
185
+ )
186
+ result_text = chat_completion.choices[0].message.content
187
+ data = json.loads(result_text)
188
+ insights = data.get("insights") if isinstance(data, dict) else None
189
+ if not isinstance(insights, list):
190
+ return None
191
+ items = [str(x).strip() for x in insights if str(x).strip()]
192
+ return items[:3]
193
+ except Exception as e:
194
+ print(f"Error calling Groq for insights: {e}")
195
+ return None
api/management/commands/fix_dates.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Commande Django pour corriger les dates de transaction erronées.
3
+ Usage: python manage.py fix_dates
4
+
5
+ Corrige toutes les transactions dont le champ `date` (envoyé par le client)
6
+ est décalé de plus de 180 jours par rapport à `created_at` (serveur).
7
+ Cela arrive quand l'horloge de l'appareil client est incorrecte.
8
+ """
9
+
10
+ from django.core.management.base import BaseCommand
11
+ from django.utils import timezone
12
+ from datetime import timedelta
13
+ from api.models import Transaction
14
+
15
+
16
+ class Command(BaseCommand):
17
+ help = "Corrige les dates de transaction client erronées (horloge appareil décalée)"
18
+
19
+ def handle(self, *args, **options):
20
+ fixed_count = 0
21
+ now = timezone.now()
22
+
23
+ transactions = Transaction.objects.all()
24
+
25
+ for tx in transactions:
26
+ try:
27
+ created_at = tx.created_at
28
+ tx_date = tx.date
29
+ if not created_at or not tx_date:
30
+ continue
31
+
32
+ delta_days = abs((tx_date - created_at).days)
33
+ if delta_days > 180:
34
+ old_date = tx.date
35
+ tx.date = created_at
36
+ tx.save(update_fields=["date"])
37
+ fixed_count += 1
38
+ self.stdout.write(
39
+ f" CORRIGÉ Transaction #{tx.id} "
40
+ f"'({tx.name})': {old_date.strftime('%Y-%m-%d')} -> "
41
+ f"{created_at.strftime('%Y-%m-%d')}"
42
+ )
43
+ except Exception as e:
44
+ self.stdout.write(
45
+ self.style.WARNING(f" ERREUR Transaction #{tx.id}: {e}")
46
+ )
47
+
48
+ self.stdout.write("")
49
+ if fixed_count > 0:
50
+ self.stdout.write(
51
+ self.style.SUCCESS(
52
+ f"{fixed_count} transaction(s) corrigée(s). "
53
+ "Les dates client ont été remplacées par les dates serveur (created_at)."
54
+ )
55
+ )
56
+ else:
57
+ self.stdout.write(
58
+ self.style.SUCCESS("Aucune transaction à corriger.")
59
+ )
api/management/commands/syscohada_audit.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from dataclasses import dataclass
5
+ from decimal import Decimal, InvalidOperation
6
+ from pathlib import Path
7
+
8
+ from django.core.management.base import BaseCommand, CommandError
9
+ from django.utils import timezone
10
+
11
+ from api.models import User
12
+ from api.syscohada_reports import (
13
+ compute_compte_resultat,
14
+ generate_bilan_csv,
15
+ generate_compte_resultat_csv,
16
+ )
17
+
18
+
19
+ def _to_decimal(value: str | None) -> Decimal:
20
+ s = (value or "").strip()
21
+ if s == "":
22
+ return Decimal("0")
23
+ # CSV exports are produced with Decimal -> str, so '.' is expected.
24
+ try:
25
+ return Decimal(s)
26
+ except InvalidOperation:
27
+ # tolerate commas in pasted files
28
+ return Decimal(s.replace(",", "."))
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DiffItem:
33
+ ref: str
34
+ field: str
35
+ expected: Decimal
36
+ actual: Decimal
37
+
38
+
39
+ class Command(BaseCommand):
40
+ help = "Audit SYSCOHADA exports vs DB calculations (CR + Bilan) for one user and year."
41
+
42
+ def add_arguments(self, parser):
43
+ parser.add_argument("--email", required=True, help="User email to audit (ex: gini@gmail.com)")
44
+ parser.add_argument("--year", type=int, default=timezone.now().year, help="Exercise year (default: current year)")
45
+ parser.add_argument("--cr-csv", default="", help="Path to compte_resultat_syscohada_<year>.csv to compare")
46
+ parser.add_argument("--bilan-csv", default="", help="Path to bilan_syscohada_<year>.csv to compare")
47
+ parser.add_argument(
48
+ "--out-md",
49
+ default="",
50
+ help="Optional output markdown path. Default: backend/Documentation/syscohada_audit_<year>_<email>.md",
51
+ )
52
+
53
+ def handle(self, *args, **options):
54
+ email: str = options["email"]
55
+ year: int = options["year"]
56
+ cr_csv_path = (options["cr_csv"] or "").strip()
57
+ bilan_csv_path = (options["bilan_csv"] or "").strip()
58
+ out_md_path = (options["out_md"] or "").strip()
59
+
60
+ try:
61
+ user = User.objects.get(email=email)
62
+ except User.DoesNotExist as e:
63
+ raise CommandError(f"User not found: {email}") from e
64
+
65
+ compte = compute_compte_resultat(user, year)
66
+
67
+ expected_cr_bytes = generate_compte_resultat_csv(compte)
68
+ expected_bilan_bytes = generate_bilan_csv(user, compte)
69
+
70
+ expected_cr = self._parse_cr_bytes(expected_cr_bytes)
71
+ expected_bilan = self._parse_bilan_bytes(expected_bilan_bytes)
72
+
73
+ diffs: list[DiffItem] = []
74
+
75
+ actual_cr = None
76
+ if cr_csv_path:
77
+ actual_cr = self._parse_cr_file(Path(cr_csv_path))
78
+ diffs.extend(self._diff_maps(expected_cr, actual_cr, fields=["MONTANT_N", "MONTANT_N_1"]))
79
+
80
+ actual_bilan = None
81
+ if bilan_csv_path:
82
+ actual_bilan = self._parse_bilan_file(Path(bilan_csv_path))
83
+ diffs.extend(
84
+ self._diff_bilan(expected_bilan, actual_bilan)
85
+ )
86
+
87
+ if not out_md_path:
88
+ safe_email = email.replace("@", "_at_").replace(".", "_")
89
+ out_md_path = str(Path("backend/Documentation") / f"syscohada_audit_{year}_{safe_email}.md")
90
+
91
+ out_path = Path(out_md_path)
92
+ out_path.parent.mkdir(parents=True, exist_ok=True)
93
+ out_path.write_text(
94
+ self._render_md(
95
+ email=email,
96
+ year=year,
97
+ user_id=user.id,
98
+ initial_balance=user.initial_balance,
99
+ compte=compte,
100
+ cr_csv_path=cr_csv_path,
101
+ bilan_csv_path=bilan_csv_path,
102
+ diffs=diffs,
103
+ compared_cr=bool(actual_cr),
104
+ compared_bilan=bool(actual_bilan),
105
+ ),
106
+ encoding="utf-8",
107
+ )
108
+
109
+ if diffs:
110
+ self.stdout.write(self.style.ERROR(f"Mismatch: {len(diffs)} difference(s). Report: {out_path}"))
111
+ else:
112
+ self.stdout.write(self.style.SUCCESS(f"OK: exports match calculations. Report: {out_path}"))
113
+
114
+ def _parse_cr_bytes(self, data: bytes) -> dict[str, dict[str, Decimal]]:
115
+ text = data.decode("utf-8")
116
+ return self._parse_cr_rows(csv.DictReader(text.splitlines()))
117
+
118
+ def _parse_cr_file(self, path: Path) -> dict[str, dict[str, Decimal]]:
119
+ if not path.exists():
120
+ raise CommandError(f"File not found: {path}")
121
+ with path.open("r", encoding="utf-8", newline="") as f:
122
+ return self._parse_cr_rows(csv.DictReader(f))
123
+
124
+ def _parse_cr_rows(self, reader: csv.DictReader) -> dict[str, dict[str, Decimal]]:
125
+ out: dict[str, dict[str, Decimal]] = {}
126
+ for row in reader:
127
+ ref = (row.get("REF") or "").strip()
128
+ if not ref:
129
+ continue
130
+ out[ref] = {
131
+ "MONTANT_N": _to_decimal(row.get("MONTANT_N")),
132
+ "MONTANT_N_1": _to_decimal(row.get("MONTANT_N_1")),
133
+ }
134
+ return out
135
+
136
+ def _parse_bilan_bytes(self, data: bytes) -> dict[tuple[str, str], dict[str, Decimal]]:
137
+ text = data.decode("utf-8")
138
+ return self._parse_bilan_rows(csv.DictReader(text.splitlines()))
139
+
140
+ def _parse_bilan_file(self, path: Path) -> dict[tuple[str, str], dict[str, Decimal]]:
141
+ if not path.exists():
142
+ raise CommandError(f"File not found: {path}")
143
+ with path.open("r", encoding="utf-8", newline="") as f:
144
+ return self._parse_bilan_rows(csv.DictReader(f))
145
+
146
+ def _parse_bilan_rows(self, reader: csv.DictReader) -> dict[tuple[str, str], dict[str, Decimal]]:
147
+ out: dict[tuple[str, str], dict[str, Decimal]] = {}
148
+ for row in reader:
149
+ section = (row.get("SECTION") or "").strip().upper()
150
+ ref = (row.get("REF") or "").strip()
151
+ if not section or not ref:
152
+ continue
153
+ out[(section, ref)] = {
154
+ "BRUT": _to_decimal(row.get("BRUT")),
155
+ "AMORT/DEPREC": _to_decimal(row.get("AMORT/DEPREC")),
156
+ "NET_N": _to_decimal(row.get("NET_N")),
157
+ "NET_N_1": _to_decimal(row.get("NET_N_1")),
158
+ }
159
+ return out
160
+
161
+ def _diff_maps(
162
+ self,
163
+ expected: dict[str, dict[str, Decimal]],
164
+ actual: dict[str, dict[str, Decimal]],
165
+ *,
166
+ fields: list[str],
167
+ ) -> list[DiffItem]:
168
+ diffs: list[DiffItem] = []
169
+ all_refs = sorted(set(expected.keys()) | set(actual.keys()))
170
+ for ref in all_refs:
171
+ e = expected.get(ref, {})
172
+ a = actual.get(ref, {})
173
+ for field in fields:
174
+ ev = e.get(field, Decimal("0"))
175
+ av = a.get(field, Decimal("0"))
176
+ if ev != av:
177
+ diffs.append(DiffItem(ref=ref, field=field, expected=ev, actual=av))
178
+ return diffs
179
+
180
+ def _diff_bilan(
181
+ self,
182
+ expected: dict[tuple[str, str], dict[str, Decimal]],
183
+ actual: dict[tuple[str, str], dict[str, Decimal]],
184
+ ) -> list[DiffItem]:
185
+ diffs: list[DiffItem] = []
186
+ keys = sorted(set(expected.keys()) | set(actual.keys()))
187
+ for key in keys:
188
+ e = expected.get(key, {})
189
+ a = actual.get(key, {})
190
+ section, ref = key
191
+ for field in ["BRUT", "AMORT/DEPREC", "NET_N", "NET_N_1"]:
192
+ ev = e.get(field, Decimal("0"))
193
+ av = a.get(field, Decimal("0"))
194
+ if ev != av:
195
+ diffs.append(DiffItem(ref=f"{section}:{ref}", field=field, expected=ev, actual=av))
196
+ return diffs
197
+
198
+ def _render_md(
199
+ self,
200
+ *,
201
+ email: str,
202
+ year: int,
203
+ user_id: int,
204
+ initial_balance: Decimal,
205
+ compte,
206
+ cr_csv_path: str,
207
+ bilan_csv_path: str,
208
+ diffs: list[DiffItem],
209
+ compared_cr: bool,
210
+ compared_bilan: bool,
211
+ ) -> str:
212
+ lines: list[str] = []
213
+ lines.append(f"# SYSCOHADA Audit — {email} — {year}")
214
+ lines.append("")
215
+ lines.append("## Contexte")
216
+ lines.append(f"- User id: `{user_id}`")
217
+ lines.append(f"- Solde initial (`initial_balance`): `{initial_balance}`")
218
+ lines.append(f"- Transactions non mappées N: `{len(compte.unmapped_tx_ids_n)}`")
219
+ lines.append(f"- Transactions non mappées N-1: `{len(compte.unmapped_tx_ids_n_1)}`")
220
+ lines.append("")
221
+ lines.append("## Sources comparées")
222
+ lines.append(f"- CR CSV fourni: `{cr_csv_path or '—'}`")
223
+ lines.append(f"- Bilan CSV fourni: `{bilan_csv_path or '—'}`")
224
+ lines.append("")
225
+ lines.append("## Résultat")
226
+ if not compared_cr and not compared_bilan:
227
+ lines.append("- Aucun fichier fourni pour comparaison. Le rapport décrit uniquement les calculs attendus.")
228
+ elif diffs:
229
+ lines.append(f"- Statut: **Mismatch** ({len(diffs)} différence(s))")
230
+ else:
231
+ lines.append("- Statut: **Match** (les exports correspondent aux calculs DB)")
232
+ lines.append("")
233
+
234
+ lines.append("## Calculs (DB)")
235
+ lines.append(f"- Total revenus N: `{compte.total_income_n}`")
236
+ lines.append(f"- Total dépenses N: `{compte.total_expense_n}`")
237
+ lines.append(f"- Résultat net N (XI): `{compte.resultat_net_n}`")
238
+ lines.append("")
239
+
240
+ if diffs:
241
+ lines.append("## Détails des différences")
242
+ lines.append("")
243
+ lines.append("| Ref | Champ | Attendu | Généré |")
244
+ lines.append("|---|---:|---:|---:|")
245
+ for d in diffs[:300]:
246
+ lines.append(f"| `{d.ref}` | `{d.field}` | `{d.expected}` | `{d.actual}` |")
247
+ if len(diffs) > 300:
248
+ lines.append("")
249
+ lines.append(f"_Diffs tronquées: {len(diffs) - 300} lignes supplémentaires._")
250
+ lines.append("")
251
+
252
+ lines.append("## Notes")
253
+ lines.append("- Les montants attendus sont calculés via `compute_compte_resultat()` + `generate_bilan_csv()`.")
254
+ lines.append("- Si les exports fournis sont à zéro, vérifier: année des transactions, `initial_balance`, et règles de mapping CR.")
255
+ lines.append("")
256
+ return "\n".join(lines)
257
+
api/migrations/0006_syscohada_mapping_and_balances.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from django.db import migrations, models
2
+ import django.db.models.deletion
3
+
4
+
5
+ class Migration(migrations.Migration):
6
+
7
+ dependencies = [
8
+ ("api", "0005_user_initial_balance"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.CreateModel(
13
+ name="SyscohadaCRMappingRule",
14
+ fields=[
15
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
16
+ ("ref", models.CharField(max_length=4)),
17
+ ("tx_type", models.CharField(blank=True, choices=[("income", "Revenu"), ("expense", "Dépense")], max_length=10, null=True)),
18
+ ("category_pattern", models.CharField(blank=True, default="", max_length=255)),
19
+ ("name_pattern", models.CharField(blank=True, default="", max_length=255)),
20
+ ("match_mode", models.CharField(choices=[("contains", "Contient"), ("regex", "Regex")], default="contains", max_length=20)),
21
+ ("priority", models.PositiveIntegerField(default=100)),
22
+ ("is_active", models.BooleanField(default=True)),
23
+ ("created_at", models.DateTimeField(auto_now_add=True)),
24
+ ("updated_at", models.DateTimeField(auto_now=True)),
25
+ ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="syscohada_cr_rules", to="api.user")),
26
+ ],
27
+ options={
28
+ "verbose_name": "Règle SYSCOHADA (CR)",
29
+ "verbose_name_plural": "Règles SYSCOHADA (CR)",
30
+ "ordering": ["priority", "-updated_at", "-id"],
31
+ },
32
+ ),
33
+ migrations.CreateModel(
34
+ name="SyscohadaBilanBalance",
35
+ fields=[
36
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
37
+ ("year", models.PositiveIntegerField()),
38
+ ("section", models.CharField(choices=[("ACTIF", "Actif"), ("PASSIF", "Passif")], max_length=10)),
39
+ ("ref", models.CharField(max_length=4)),
40
+ ("brut", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)),
41
+ ("amort", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)),
42
+ ("net", models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True)),
43
+ ("note", models.CharField(blank=True, default="", max_length=50)),
44
+ ("created_at", models.DateTimeField(auto_now_add=True)),
45
+ ("updated_at", models.DateTimeField(auto_now=True)),
46
+ ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="syscohada_bilan_balances", to="api.user")),
47
+ ],
48
+ options={
49
+ "verbose_name": "Solde SYSCOHADA (Bilan)",
50
+ "verbose_name_plural": "Soldes SYSCOHADA (Bilan)",
51
+ "ordering": ["year", "section", "ref"],
52
+ "unique_together": {("user", "year", "section", "ref")},
53
+ },
54
+ ),
55
+ migrations.AddIndex(
56
+ model_name="syscohadacrmappingrule",
57
+ index=models.Index(fields=["user", "is_active", "priority"], name="api_syscoh_user_id_ea86a0_idx"),
58
+ ),
59
+ migrations.AddIndex(
60
+ model_name="syscohadacrmappingrule",
61
+ index=models.Index(fields=["user", "tx_type"], name="api_syscoh_user_id_83c3af_idx"),
62
+ ),
63
+ migrations.AddIndex(
64
+ model_name="syscohadacrmappingrule",
65
+ index=models.Index(fields=["user", "ref"], name="api_syscoh_user_id_03664d_idx"),
66
+ ),
67
+ migrations.AddIndex(
68
+ model_name="syscohadabilanbalance",
69
+ index=models.Index(fields=["user", "year", "section"], name="api_syscoh_user_id_8bc49d_idx"),
70
+ ),
71
+ migrations.AddIndex(
72
+ model_name="syscohadabilanbalance",
73
+ index=models.Index(fields=["user", "year", "ref"], name="api_syscoh_user_id_824e07_idx"),
74
+ ),
75
+ ]
76
+
api/models.py CHANGED
@@ -295,4 +295,104 @@ class AIInsight(models.Model):
295
 
296
  def __str__(self):
297
  return f"Insight pour {self.user.email} - {self.created_at}"
298
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
  def __str__(self):
297
  return f"Insight pour {self.user.email} - {self.created_at}"
298
+
299
+
300
+ class SyscohadaCRMappingRule(models.Model):
301
+ """
302
+ Règle de mapping Transaction -> Compte de résultat SYSCOHADA (ref).
303
+
304
+ Objectif: rendre le calcul traçable et configurable par utilisateur.
305
+ Le moteur tente d'appliquer la règle la plus prioritaire qui match
306
+ (type + catégorie/nom via contains ou regex).
307
+ """
308
+
309
+ MATCH_MODE_CHOICES = [
310
+ ("contains", "Contient"),
311
+ ("regex", "Regex"),
312
+ ]
313
+
314
+ TX_TYPE_CHOICES = [
315
+ ("income", "Revenu"),
316
+ ("expense", "Dépense"),
317
+ ]
318
+
319
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="syscohada_cr_rules")
320
+
321
+ # Référence SYSCOHADA (ex: TA, TC, RA, RG, RH, RJ, ...)
322
+ ref = models.CharField(max_length=4)
323
+
324
+ # Optionnel: limiter aux revenus/dépenses
325
+ tx_type = models.CharField(max_length=10, choices=TX_TYPE_CHOICES, blank=True, null=True)
326
+
327
+ # Champs sur lesquels matcher
328
+ category_pattern = models.CharField(max_length=255, blank=True, default="")
329
+ name_pattern = models.CharField(max_length=255, blank=True, default="")
330
+ match_mode = models.CharField(max_length=20, choices=MATCH_MODE_CHOICES, default="contains")
331
+
332
+ # Plus petit = plus prioritaire
333
+ priority = models.PositiveIntegerField(default=100)
334
+ is_active = models.BooleanField(default=True)
335
+
336
+ created_at = models.DateTimeField(auto_now_add=True)
337
+ updated_at = models.DateTimeField(auto_now=True)
338
+
339
+ class Meta:
340
+ verbose_name = "Règle SYSCOHADA (CR)"
341
+ verbose_name_plural = "Règles SYSCOHADA (CR)"
342
+ ordering = ["priority", "-updated_at", "-id"]
343
+ indexes = [
344
+ models.Index(fields=["user", "is_active", "priority"]),
345
+ models.Index(fields=["user", "tx_type"]),
346
+ models.Index(fields=["user", "ref"]),
347
+ ]
348
+
349
+ def __str__(self):
350
+ return f"{self.user.email} -> {self.ref} (prio {self.priority})"
351
+
352
+
353
+ class SyscohadaBilanBalance(models.Model):
354
+ """
355
+ Soldes SYSCOHADA saisis/importés (bilan) par utilisateur et exercice.
356
+
357
+ Important:
358
+ - Akompta n'ayant pas (encore) une comptabilité en partie double,
359
+ beaucoup de postes du bilan doivent être saisis (ou importés).
360
+ - Le système auto-calcule BS (trésorerie) et CJ (résultat net) à partir
361
+ des transactions + compte de résultat.
362
+ """
363
+
364
+ SECTION_CHOICES = [
365
+ ("ACTIF", "Actif"),
366
+ ("PASSIF", "Passif"),
367
+ ]
368
+
369
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="syscohada_bilan_balances")
370
+ year = models.PositiveIntegerField()
371
+ section = models.CharField(max_length=10, choices=SECTION_CHOICES)
372
+
373
+ # Référence SYSCOHADA (ex: BS, BI, CA, DJ, ...)
374
+ ref = models.CharField(max_length=4)
375
+
376
+ # Actif: BRUT/AMORT pour calculer NET. Passif: NET uniquement.
377
+ brut = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True)
378
+ amort = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True)
379
+ net = models.DecimalField(max_digits=15, decimal_places=2, blank=True, null=True)
380
+
381
+ note = models.CharField(max_length=50, blank=True, default="")
382
+
383
+ created_at = models.DateTimeField(auto_now_add=True)
384
+ updated_at = models.DateTimeField(auto_now=True)
385
+
386
+ class Meta:
387
+ verbose_name = "Solde SYSCOHADA (Bilan)"
388
+ verbose_name_plural = "Soldes SYSCOHADA (Bilan)"
389
+ ordering = ["year", "section", "ref"]
390
+ unique_together = ["user", "year", "section", "ref"]
391
+ indexes = [
392
+ models.Index(fields=["user", "year", "section"]),
393
+ models.Index(fields=["user", "year", "ref"]),
394
+ ]
395
+
396
+ def __str__(self):
397
+ return f"{self.user.email} {self.year} {self.section} {self.ref}"
398
+
api/serializers.py CHANGED
@@ -2,7 +2,16 @@ from rest_framework import serializers
2
  from django.contrib.auth import get_user_model
3
  from django.contrib.auth.password_validation import validate_password
4
  from django.utils import timezone
5
- from .models import Product, Transaction, Budget, Ad, Notification, SupportTicket
 
 
 
 
 
 
 
 
 
6
 
7
  User = get_user_model()
8
 
@@ -127,6 +136,39 @@ class TransactionSerializer(serializers.ModelSerializer):
127
  'currency', 'created_at', 'updated_at'
128
  ]
129
  read_only_fields = ['id', 'created_at', 'updated_at']
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  def create(self, validated_data):
132
  validated_data['user'] = self.context['request'].user
@@ -246,4 +288,48 @@ class SupportTicketSerializer(serializers.ModelSerializer):
246
 
247
  def create(self, validated_data):
248
  validated_data['user'] = self.context['request'].user
249
- return super().create(validated_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from django.contrib.auth import get_user_model
3
  from django.contrib.auth.password_validation import validate_password
4
  from django.utils import timezone
5
+ from .models import (
6
+ Product,
7
+ Transaction,
8
+ Budget,
9
+ Ad,
10
+ Notification,
11
+ SupportTicket,
12
+ SyscohadaCRMappingRule,
13
+ SyscohadaBilanBalance,
14
+ )
15
 
16
  User = get_user_model()
17
 
 
136
  'currency', 'created_at', 'updated_at'
137
  ]
138
  read_only_fields = ['id', 'created_at', 'updated_at']
139
+
140
+ def validate_date(self, value):
141
+ """
142
+ Protège contre les horloges clients incorrectes.
143
+
144
+ Compare la date envoyée par le client avec `created_at` (serveur)
145
+ si la transaction existe déjà, sinon avec `timezone.now()`.
146
+
147
+ Si l'écart dépasse 180 jours, on remplace par la date serveur de
148
+ référence (created_at / timezone.now).
149
+
150
+ Override possible: passer `?allow_backdate=1` sur la requête.
151
+ """
152
+ request = self.context.get("request")
153
+ if request and request.query_params.get("allow_backdate") == "1":
154
+ return value
155
+
156
+ # Utiliser created_at (serveur) comme référence si disponible
157
+ reference = (
158
+ self.instance.created_at
159
+ if self.instance and self.instance.created_at
160
+ else timezone.now()
161
+ )
162
+
163
+ try:
164
+ delta_days = abs((value - reference).days)
165
+ except Exception:
166
+ return reference
167
+
168
+ if delta_days > 180:
169
+ return reference
170
+
171
+ return value
172
 
173
  def create(self, validated_data):
174
  validated_data['user'] = self.context['request'].user
 
288
 
289
  def create(self, validated_data):
290
  validated_data['user'] = self.context['request'].user
291
+ return super().create(validated_data)
292
+
293
+
294
+ class SyscohadaCRMappingRuleSerializer(serializers.ModelSerializer):
295
+ class Meta:
296
+ model = SyscohadaCRMappingRule
297
+ fields = [
298
+ "id",
299
+ "ref",
300
+ "tx_type",
301
+ "category_pattern",
302
+ "name_pattern",
303
+ "match_mode",
304
+ "priority",
305
+ "is_active",
306
+ "created_at",
307
+ "updated_at",
308
+ ]
309
+ read_only_fields = ["id", "created_at", "updated_at"]
310
+
311
+ def create(self, validated_data):
312
+ validated_data["user"] = self.context["request"].user
313
+ return super().create(validated_data)
314
+
315
+
316
+ class SyscohadaBilanBalanceSerializer(serializers.ModelSerializer):
317
+ class Meta:
318
+ model = SyscohadaBilanBalance
319
+ fields = [
320
+ "id",
321
+ "year",
322
+ "section",
323
+ "ref",
324
+ "brut",
325
+ "amort",
326
+ "net",
327
+ "note",
328
+ "created_at",
329
+ "updated_at",
330
+ ]
331
+ read_only_fields = ["id", "created_at", "updated_at"]
332
+
333
+ def create(self, validated_data):
334
+ validated_data["user"] = self.context["request"].user
335
+ return super().create(validated_data)
api/syscohada_reports.py CHANGED
@@ -10,6 +10,7 @@ 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
@@ -33,34 +34,132 @@ 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})|([+-])")
@@ -96,6 +195,8 @@ class CompteResultatComputed:
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:
@@ -105,26 +206,43 @@ def compute_compte_resultat(user: User, year: int) -> CompteResultatComputed:
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":
@@ -135,7 +253,15 @@ def compute_compte_resultat(user: User, year: int) -> CompteResultatComputed:
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":
@@ -165,9 +291,150 @@ def compute_compte_resultat(user: User, year: int) -> CompteResultatComputed:
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"]
@@ -193,26 +460,53 @@ 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
 
@@ -245,16 +539,7 @@ def generate_bilan_csv(user: User, compte: CompteResultatComputed) -> bytes:
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"))
@@ -310,4 +595,3 @@ def generate_bilan_csv(user: User, compte: CompteResultatComputed) -> bytes:
310
  )
311
 
312
  return out.getvalue().encode("utf-8")
313
-
 
10
  from pathlib import Path
11
  from typing import Any
12
 
13
+ from django.db import models
14
  from django.utils import timezone
15
 
16
  from .models import Transaction, User
 
34
  return (value or "").strip().lower()
35
 
36
 
37
+ def _pick_effective_datetime(tx: Transaction) -> datetime:
38
  """
39
+ Choisit la date "effective" d'une transaction pour les rapports.
40
+
41
+ Contexte: certains clients envoient une `date` incorrecte (ex: horloge appareil en 2024)
42
+ alors que `created_at` (serveur) est correcte (2026).
43
+
44
+ Règle:
45
+ - si l'écart absolu entre `date` et `created_at` dépasse 180 jours,
46
+ on utilise `created_at` comme date effective.
47
+ - sinon on conserve `date`.
48
+ """
49
+ try:
50
+ created_at = tx.created_at
51
+ tx_date = tx.date
52
+ if created_at and tx_date:
53
+ delta_days = abs((tx_date - created_at).days)
54
+ if delta_days > 180:
55
+ return created_at
56
+ return tx_date
57
+ except Exception:
58
+ return tx.date
59
+
60
+
61
+ def _in_year_bounds(tx: Transaction, start: datetime, end: datetime) -> bool:
62
+ eff = _pick_effective_datetime(tx)
63
+ return start <= eff < end
64
+
65
+
66
+ def _map_transaction_to_cr_ref_from_user_rules(user: User, tx: Transaction) -> tuple[str | None, bool]:
67
+ """
68
+ Map via règles utilisateur (priorité) si disponibles.
69
+ Retourne: (ref|None, matched_via_rule)
70
+ """
71
+ from .models import SyscohadaCRMappingRule
72
+
73
+ try:
74
+ rules = SyscohadaCRMappingRule.objects.filter(user=user, is_active=True).order_by("priority", "-updated_at", "-id")
75
+ except Exception:
76
+ # Si les migrations ne sont pas appliquées ou table absente, ignorer les règles
77
+ return None, False
78
+ if not rules.exists():
79
+ return None, False
80
+
81
+ category = _normalize_text(getattr(tx, "category", ""))
82
+ name = _normalize_text(getattr(tx, "name", ""))
83
+
84
+ for rule in rules:
85
+ if rule.tx_type and rule.tx_type != tx.type:
86
+ continue
87
+
88
+ cat_pat = (rule.category_pattern or "").strip()
89
+ name_pat = (rule.name_pattern or "").strip()
90
+
91
+ # Wildcard rule (no patterns) is allowed for explicit fallbacks
92
+ if rule.match_mode == "contains":
93
+ ok_cat = True if not cat_pat else _normalize_text(cat_pat) in category
94
+ ok_name = True if not name_pat else _normalize_text(name_pat) in name
95
+ if ok_cat and ok_name:
96
+ return rule.ref, True
97
+ else: # regex
98
+ ok_cat = True
99
+ ok_name = True
100
+ try:
101
+ if cat_pat:
102
+ ok_cat = re.search(cat_pat, category, flags=re.IGNORECASE) is not None
103
+ if name_pat:
104
+ ok_name = re.search(name_pat, name, flags=re.IGNORECASE) is not None
105
+ except re.error:
106
+ # Si regex invalide: ignorer la règle (robustesse)
107
+ continue
108
+
109
+ if ok_cat and ok_name:
110
+ return rule.ref, True
111
+
112
+ return None, False
113
+
114
+
115
+ def _map_transaction_to_cr_ref_default(tx: Transaction) -> str | None:
116
+ """
117
+ Mapping "par défaut" (sans règles utilisateur) basé sur mots-clés.
118
+ Retourne None si aucune catégorie n'est reconnue (=> transaction non mappée).
119
  """
120
  category = _normalize_text(getattr(tx, "category", ""))
121
  name = _normalize_text(getattr(tx, "name", ""))
122
  haystack = f"{category} {name}".strip()
123
 
124
  if tx.type == "income":
125
+ if any(k in haystack for k in ["service", "prestation", "consult", "honoraire"]):
126
+ return "TC" # travaux / services vendus
127
+ if any(k in haystack for k in ["accessoire"]):
128
  return "TD"
129
+ if any(k in haystack for k in ["vente", "ventes", "marchandise", "produit", "produits"]):
130
+ return "TA"
131
+ return None
132
 
133
  # expense
134
+ if any(k in haystack for k in ["achat", "achats", "marchandise", "appro", "approvisionnement", "fournisseur"]):
135
  return "RA"
136
+ if any(k in haystack for k in ["transport", "taxi", "bus", "essence", "carburant", "livraison", "deplacement", "déplacement"]):
137
  return "RG"
138
+ if any(
139
+ k in haystack
140
+ for k in [
141
+ "loyer",
142
+ "internet",
143
+ "eau",
144
+ "electric",
145
+ "électric",
146
+ "telephone",
147
+ "téléphone",
148
+ "prestataire",
149
+ "maintenance",
150
+ "marketing",
151
+ "publicit",
152
+ "publicité",
153
+ "pub",
154
+ "assurance",
155
+ ]
156
+ ):
157
  return "RH"
158
+ if any(k in haystack for k in ["impot", "impôt", "taxe", "douane", "etat", "état", "tva"]):
159
  return "RI"
160
+ if any(k in haystack for k in ["salaire", "salaires", "personnel", "paie", "payroll", "prime"]):
161
  return "RK"
162
+ return None
163
 
164
 
165
  _CR_TOKEN_RE = re.compile(r"([A-Z]{1,2})|([+-])")
 
195
  total_expense_n: Decimal
196
  total_income_n_1: Decimal
197
  total_expense_n_1: Decimal
198
+ unmapped_tx_ids_n: list[int]
199
+ unmapped_tx_ids_n_1: list[int]
200
 
201
 
202
  def compute_compte_resultat(user: User, year: int) -> CompteResultatComputed:
 
206
  start_n, end_n = _year_bounds(year)
207
  start_n_1, end_n_1 = _year_bounds(year - 1)
208
 
209
+ # Important: include both date- and created_at-based windows, then decide per-row
210
+ # to handle device clock issues (date far from created_at).
211
+ tx_candidates_n = Transaction.objects.filter(
212
+ user=user,
213
+ ).filter(
214
+ (models.Q(date__gte=start_n, date__lt=end_n))
215
+ | (models.Q(created_at__gte=start_n, created_at__lt=end_n))
216
+ ).only("id", "amount", "type", "category", "name", "date", "created_at")
217
+
218
+ tx_candidates_n_1 = Transaction.objects.filter(
219
+ user=user,
220
+ ).filter(
221
+ (models.Q(date__gte=start_n_1, date__lt=end_n_1))
222
+ | (models.Q(created_at__gte=start_n_1, created_at__lt=end_n_1))
223
+ ).only("id", "amount", "type", "category", "name", "date", "created_at")
224
+
225
+ tx_n = [tx for tx in tx_candidates_n if _in_year_bounds(tx, start_n, end_n)]
226
+ tx_n_1 = [tx for tx in tx_candidates_n_1 if _in_year_bounds(tx, start_n_1, end_n_1)]
227
 
228
  values_n: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in lignes}
229
  values_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in lignes}
230
+ unmapped_tx_ids_n: list[int] = []
231
+ unmapped_tx_ids_n_1: list[int] = []
232
 
233
  total_income_n = Decimal("0")
234
  total_expense_n = Decimal("0")
235
  for tx in tx_n:
236
+ ref, matched_via_rule = _map_transaction_to_cr_ref_from_user_rules(user, tx)
237
+ if not ref:
238
+ ref = _map_transaction_to_cr_ref_default(tx)
239
+ if not ref:
240
+ unmapped_tx_ids_n.append(tx.id)
241
+ ref = "RJ" # Divers / fallback (si présent dans le template)
242
+ if ref not in values_n:
243
+ # ref inconnue => fallback RJ + marquer unmapped
244
+ unmapped_tx_ids_n.append(tx.id)
245
+ ref = "RJ"
246
  amount = Decimal(tx.amount)
247
  values_n[ref] = values_n.get(ref, Decimal("0")) + amount
248
  if tx.type == "income":
 
253
  total_income_n_1 = Decimal("0")
254
  total_expense_n_1 = Decimal("0")
255
  for tx in tx_n_1:
256
+ ref, matched_via_rule = _map_transaction_to_cr_ref_from_user_rules(user, tx)
257
+ if not ref:
258
+ ref = _map_transaction_to_cr_ref_default(tx)
259
+ if not ref:
260
+ unmapped_tx_ids_n_1.append(tx.id)
261
+ ref = "RJ"
262
+ if ref not in values_n_1:
263
+ unmapped_tx_ids_n_1.append(tx.id)
264
+ ref = "RJ"
265
  amount = Decimal(tx.amount)
266
  values_n_1[ref] = values_n_1.get(ref, Decimal("0")) + amount
267
  if tx.type == "income":
 
291
  total_expense_n=total_expense_n,
292
  total_income_n_1=total_income_n_1,
293
  total_expense_n_1=total_expense_n_1,
294
+ unmapped_tx_ids_n=unmapped_tx_ids_n,
295
+ unmapped_tx_ids_n_1=unmapped_tx_ids_n_1,
296
  )
297
 
298
 
299
+ def compute_bilan_values(user: User, year: int, compte: CompteResultatComputed) -> dict[str, object]:
300
+ """
301
+ Calcule les valeurs du bilan (Actif/Passif) en combinant:
302
+ - soldes saisis/importés (SyscohadaBilanBalance)
303
+ - auto-calc: BS (trésorerie) et CJ (résultat net)
304
+
305
+ Retourne une structure JSON-friendly utilisable par preview + export.
306
+ """
307
+ from .models import SyscohadaBilanBalance
308
+
309
+ structure = _load_template_json("bilan_structure.json")
310
+ actif: list[dict[str, Any]] = structure["actif"]
311
+ passif: list[dict[str, Any]] = structure["passif"]
312
+
313
+ # Load user balances for N and N-1 (support colonnes N et N-1)
314
+ try:
315
+ # Force evaluation inside try: sqlite can raise "no such table" only at iteration time.
316
+ balances_n = list(SyscohadaBilanBalance.objects.filter(user=user, year=year))
317
+ balances_n_1 = list(SyscohadaBilanBalance.objects.filter(user=user, year=year - 1))
318
+ except Exception:
319
+ # Table absente / migrations non appliquées: fallback sans soldes saisis
320
+ balances_n = []
321
+ balances_n_1 = []
322
+
323
+ def split_balances(qs):
324
+ actif_bal: dict[str, dict[str, Decimal]] = {}
325
+ passif_bal: dict[str, Decimal] = {}
326
+ for b in qs:
327
+ if b.section == "ACTIF":
328
+ actif_bal[b.ref] = {
329
+ "brut": Decimal(b.brut or 0),
330
+ "amort": Decimal(b.amort or 0),
331
+ }
332
+ else:
333
+ passif_bal[b.ref] = Decimal(b.net or 0)
334
+ return actif_bal, passif_bal
335
+
336
+ actif_bal_n, passif_bal_n = split_balances(balances_n)
337
+ actif_bal_n_1, passif_bal_n_1 = split_balances(balances_n_1)
338
+
339
+ # Auto-calc BS (cash) for N and N-1
340
+ cash_n = user.initial_balance + compte.total_income_n - compte.total_expense_n
341
+ cash_n_1 = user.initial_balance + compte.total_income_n_1 - compte.total_expense_n_1
342
+
343
+ actif_bal_n["BS"] = {"brut": Decimal(cash_n), "amort": Decimal("0")}
344
+ actif_bal_n_1["BS"] = {"brut": Decimal(cash_n_1), "amort": Decimal("0")}
345
+
346
+ # Auto-calc CJ (resultat net) in passif (if template contains CJ)
347
+ passif_bal_n["CJ"] = Decimal(compte.resultat_net_n)
348
+ # For N-1 we use computed XI if present; otherwise 0
349
+ passif_bal_n_1["CJ"] = Decimal(compte.values_n_1.get("XI", Decimal("0")))
350
+
351
+ # Compute totals (validation): TOTAL ACTIF (BZ) vs TOTAL PASSIF (DZ)
352
+ def compute_totals_for(
353
+ actif_bal: dict[str, dict[str, Decimal]],
354
+ passif_bal: dict[str, Decimal],
355
+ cash: Decimal,
356
+ resultat: Decimal,
357
+ ) -> dict[str, str]:
358
+ brut: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
359
+ amort: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
360
+ for ref, v in actif_bal.items():
361
+ brut[ref] = Decimal(v.get("brut", 0))
362
+ amort[ref] = Decimal(v.get("amort", 0))
363
+
364
+ brut["BS"] = Decimal(cash)
365
+ amort["BS"] = Decimal("0")
366
+
367
+ net_passif: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
368
+ for ref, v in passif_bal.items():
369
+ net_passif[ref] = Decimal(v)
370
+ net_passif["CJ"] = Decimal(resultat)
371
+
372
+ def net_for(ref: str) -> Decimal:
373
+ return brut.get(ref, Decimal("0")) - amort.get(ref, Decimal("0"))
374
+
375
+ # Compute header subtotals (stable SYSCOHADA groupings)
376
+ header_groups = {
377
+ "AD": ["AE", "AF", "AG", "AH"],
378
+ "AI": ["AJ", "AK", "AL", "AM", "AN", "AP"],
379
+ "AQ": ["AR", "AS"],
380
+ "BG": ["BH", "BI", "BJ"],
381
+ }
382
+ for header_ref, children in header_groups.items():
383
+ brut[header_ref] = sum((brut.get(c, Decimal("0")) for c in children), Decimal("0"))
384
+ amort[header_ref] = sum((amort.get(c, Decimal("0")) for c in children), Decimal("0"))
385
+
386
+ # Compute totals based on formulas (only '+' is expected in these bilan totals)
387
+ def parse_sum_formula(formula: str) -> list[str]:
388
+ return [part.strip() for part in formula.split("+") if part.strip()]
389
+
390
+ for item in actif:
391
+ if not item.get("is_total"):
392
+ continue
393
+ parts = parse_sum_formula(item.get("formula", ""))
394
+ brut[item["ref"]] = sum((brut.get(p, Decimal("0")) for p in parts), Decimal("0"))
395
+ amort[item["ref"]] = sum((amort.get(p, Decimal("0")) for p in parts), Decimal("0"))
396
+
397
+ passif_meta: dict[str, dict[str, Any]] = {item["ref"]: item for item in passif}
398
+
399
+ def signed_passif_value(ref: str) -> Decimal:
400
+ val = net_passif.get(ref, Decimal("0"))
401
+ meta = passif_meta.get(ref, {})
402
+ if meta.get("is_negative"):
403
+ return -val
404
+ return val
405
+
406
+ for item in passif:
407
+ if not item.get("is_total"):
408
+ continue
409
+ parts = parse_sum_formula(item.get("formula", ""))
410
+ net_passif[item["ref"]] = sum((signed_passif_value(p) for p in parts), Decimal("0"))
411
+
412
+ total_actif = net_for("BZ")
413
+ total_passif = signed_passif_value("DZ")
414
+ delta = total_actif - total_passif
415
+ return {
416
+ "total_actif": str(total_actif),
417
+ "total_passif": str(total_passif),
418
+ "delta": str(delta),
419
+ }
420
+
421
+ totals_n = compute_totals_for(actif_bal_n, passif_bal_n, cash_n, compte.resultat_net_n)
422
+ totals_n_1 = compute_totals_for(actif_bal_n_1, passif_bal_n_1, cash_n_1, passif_bal_n_1["CJ"])
423
+
424
+ return {
425
+ "year": year,
426
+ "auto": {
427
+ "BS": {"net_n": str(cash_n), "net_n_1": str(cash_n_1)},
428
+ "CJ": {"net_n": str(compte.resultat_net_n), "net_n_1": str(passif_bal_n_1["CJ"])},
429
+ },
430
+ "totals": {"n": totals_n, "n_1": totals_n_1},
431
+ "actif": {ref: {"brut": str(v["brut"]), "amort": str(v["amort"])} for ref, v in actif_bal_n.items()},
432
+ "passif": {ref: str(v) for ref, v in passif_bal_n.items()},
433
+ "actif_n_1": {ref: {"brut": str(v["brut"]), "amort": str(v["amort"])} for ref, v in actif_bal_n_1.items()},
434
+ "passif_n_1": {ref: str(v) for ref, v in passif_bal_n_1.items()},
435
+ }
436
+
437
+
438
  def generate_compte_resultat_csv(compte: CompteResultatComputed) -> bytes:
439
  structure = _load_template_json("compte_resultat_structure.json")
440
  lignes: list[dict[str, Any]] = structure["lignes"]
 
460
  structure = _load_template_json("bilan_structure.json")
461
  actif: list[dict[str, Any]] = structure["actif"]
462
  passif: list[dict[str, Any]] = structure["passif"]
463
+ from .models import SyscohadaBilanBalance
 
 
 
 
 
 
464
 
465
  # Actif values stored by ref: BRUT, AMORT, NET_N, NET_N_1
466
  brut: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
467
  amort: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
468
+ brut_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
469
+ amort_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
470
+
471
+ # Passif values stored by ref: NET_N, NET_N_1
472
+ passif_meta: dict[str, dict[str, Any]] = {item["ref"]: item for item in passif}
473
+ net_passif_n: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
474
+ net_passif_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
475
+
476
+ # 1) Load user-provided balances (N and N-1) (optional)
477
+ try:
478
+ # Force evaluation inside try: sqlite can raise "no such table" only at iteration time.
479
+ balances_n = list(SyscohadaBilanBalance.objects.filter(user=user, year=compte.year))
480
+ balances_n_1 = list(SyscohadaBilanBalance.objects.filter(user=user, year=compte.year - 1))
481
+ except Exception:
482
+ balances_n = []
483
+ balances_n_1 = []
484
+
485
+ for b in balances_n:
486
+ if b.section == "ACTIF":
487
+ brut[b.ref] = Decimal(b.brut or 0)
488
+ amort[b.ref] = Decimal(b.amort or 0)
489
+ else:
490
+ net_passif_n[b.ref] = Decimal(b.net or 0)
491
+
492
+ for b in balances_n_1:
493
+ if b.section == "ACTIF":
494
+ brut_n_1[b.ref] = Decimal(b.brut or 0)
495
+ amort_n_1[b.ref] = Decimal(b.amort or 0)
496
+ else:
497
+ net_passif_n_1[b.ref] = Decimal(b.net or 0)
498
 
499
+ # 2) Auto-calc: cash (BS) + result (CJ)
500
+ cash_n = user.initial_balance + compte.total_income_n - compte.total_expense_n
501
+ cash_n_1 = user.initial_balance + compte.total_income_n_1 - compte.total_expense_n_1
502
  brut["BS"] = Decimal(cash_n)
503
  amort["BS"] = Decimal("0")
 
 
 
504
  brut_n_1["BS"] = Decimal(cash_n_1)
505
  amort_n_1["BS"] = Decimal("0")
506
 
507
+ net_passif_n["CJ"] = Decimal(compte.resultat_net_n)
508
+ net_passif_n_1["CJ"] = Decimal(compte.values_n_1.get("XI", Decimal("0")))
509
+
510
  def net_for(ref: str) -> Decimal:
511
  return brut.get(ref, Decimal("0")) - amort.get(ref, Decimal("0"))
512
 
 
539
  brut_n_1[item["ref"]] = sum((brut_n_1.get(p, Decimal("0")) for p in parts), Decimal("0"))
540
  amort_n_1[item["ref"]] = sum((amort_n_1.get(p, Decimal("0")) for p in parts), Decimal("0"))
541
 
542
+ # Notes: CA (capital) et autres postes doivent venir des soldes saisis/importés.
 
 
 
 
 
 
 
 
 
543
 
544
  def signed_passif_value(values: dict[str, Decimal], ref: str) -> Decimal:
545
  val = values.get(ref, Decimal("0"))
 
595
  )
596
 
597
  return out.getvalue().encode("utf-8")
 
api/tests_syscohada.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from decimal import Decimal
5
+
6
+ from django.test import TestCase
7
+ from django.utils import timezone
8
+
9
+ from .models import (
10
+ Transaction,
11
+ User,
12
+ SyscohadaCRMappingRule,
13
+ SyscohadaBilanBalance,
14
+ )
15
+ from .syscohada_reports import compute_compte_resultat, generate_bilan_csv, generate_compte_resultat_csv
16
+ from .serializers import TransactionSerializer
17
+
18
+
19
+ class SyscohadaComputationTests(TestCase):
20
+ def setUp(self):
21
+ self.user = User.objects.create_user(
22
+ email="syscohada@test.com",
23
+ password="pass12345",
24
+ first_name="Sys",
25
+ last_name="Coha",
26
+ account_type="personal",
27
+ initial_balance=Decimal("1000.00"),
28
+ agreed_terms=True,
29
+ )
30
+
31
+ def _dt(self, year: int, month: int, day: int):
32
+ return timezone.make_aware(datetime(year, month, day, 12, 0, 0))
33
+
34
+ def test_compte_resultat_marks_unmapped_transactions(self):
35
+ # Year N
36
+ tx_income = Transaction.objects.create(
37
+ user=self.user,
38
+ name="Vente marchandises",
39
+ amount=Decimal("5000.00"),
40
+ type="income",
41
+ category="Ventes",
42
+ date=self._dt(2026, 2, 2),
43
+ currency="XOF",
44
+ )
45
+ tx_transport = Transaction.objects.create(
46
+ user=self.user,
47
+ name="Taxi",
48
+ amount=Decimal("200.00"),
49
+ type="expense",
50
+ category="Transport",
51
+ date=self._dt(2026, 2, 3),
52
+ currency="XOF",
53
+ )
54
+ tx_unknown = Transaction.objects.create(
55
+ user=self.user,
56
+ name="Dépense inconnue",
57
+ amount=Decimal("50.00"),
58
+ type="expense",
59
+ category="Inconnu",
60
+ date=self._dt(2026, 2, 4),
61
+ currency="XOF",
62
+ )
63
+
64
+ compte = compute_compte_resultat(self.user, 2026)
65
+
66
+ # TA should include the income (default mapping sees "ventes")
67
+ self.assertGreaterEqual(compte.values_n.get("TA", Decimal("0")), Decimal("5000.00"))
68
+
69
+ # RG should include transport
70
+ self.assertGreaterEqual(compte.values_n.get("RG", Decimal("0")), Decimal("200.00"))
71
+
72
+ # Unknown should be marked as unmapped (and goes to RJ fallback)
73
+ self.assertIn(tx_unknown.id, compte.unmapped_tx_ids_n)
74
+ self.assertGreaterEqual(compte.values_n.get("RJ", Decimal("0")), Decimal("50.00"))
75
+
76
+ # If we add an explicit rule, it should stop being "unmapped"
77
+ SyscohadaCRMappingRule.objects.create(
78
+ user=self.user,
79
+ ref="RH",
80
+ tx_type="expense",
81
+ category_pattern="Inconnu",
82
+ match_mode="contains",
83
+ priority=1,
84
+ is_active=True,
85
+ )
86
+ compte2 = compute_compte_resultat(self.user, 2026)
87
+ self.assertNotIn(tx_unknown.id, compte2.unmapped_tx_ids_n)
88
+ self.assertGreaterEqual(compte2.values_n.get("RH", Decimal("0")), Decimal("50.00"))
89
+
90
+ def test_bilan_can_balance_with_user_inputs(self):
91
+ # Create a simple year with net profit
92
+ Transaction.objects.create(
93
+ user=self.user,
94
+ name="Vente",
95
+ amount=Decimal("1000.00"),
96
+ type="income",
97
+ category="Ventes",
98
+ date=self._dt(2026, 1, 10),
99
+ currency="XOF",
100
+ )
101
+ Transaction.objects.create(
102
+ user=self.user,
103
+ name="Transport",
104
+ amount=Decimal("100.00"),
105
+ type="expense",
106
+ category="Transport",
107
+ date=self._dt(2026, 1, 11),
108
+ currency="XOF",
109
+ )
110
+
111
+ compte = compute_compte_resultat(self.user, 2026)
112
+ cash = self.user.initial_balance + compte.total_income_n - compte.total_expense_n
113
+ resultat = compte.resultat_net_n
114
+
115
+ # Provide capital so that: CA + CJ == BS (minimal bilan)
116
+ SyscohadaBilanBalance.objects.create(
117
+ user=self.user,
118
+ year=2026,
119
+ section="PASSIF",
120
+ ref="CA",
121
+ net=(cash - resultat),
122
+ note="Capital (test)",
123
+ )
124
+
125
+ bilan_csv = generate_bilan_csv(self.user, compte).decode("utf-8")
126
+ # Sanity: contains key refs
127
+ self.assertIn("ACTIF,BS", bilan_csv)
128
+ self.assertIn("PASSIF,CA", bilan_csv)
129
+ self.assertIn("PASSIF,CJ", bilan_csv)
130
+
131
+ def test_exports_generate_csv_bytes(self):
132
+ Transaction.objects.create(
133
+ user=self.user,
134
+ name="Vente",
135
+ amount=Decimal("100.00"),
136
+ type="income",
137
+ category="Ventes",
138
+ date=self._dt(2026, 3, 1),
139
+ currency="XOF",
140
+ )
141
+ compte = compute_compte_resultat(self.user, 2026)
142
+ cr_csv = generate_compte_resultat_csv(compte)
143
+ bilan_csv = generate_bilan_csv(self.user, compte)
144
+ self.assertIsInstance(cr_csv, (bytes, bytearray))
145
+ self.assertIsInstance(bilan_csv, (bytes, bytearray))
146
+ self.assertGreater(len(cr_csv), 50)
147
+ self.assertGreater(len(bilan_csv), 50)
148
+
149
+ def test_uses_created_at_when_transaction_date_is_way_off(self):
150
+ """
151
+ If client sends a wrong `date` (ex: device clock in 2024) but the server-side
152
+ `created_at` is in 2026, SYSCOHADA calculations should include it in 2026.
153
+ """
154
+ tx = Transaction.objects.create(
155
+ user=self.user,
156
+ name="Vente (bad date)",
157
+ amount=Decimal("100.00"),
158
+ type="income",
159
+ category="Ventes",
160
+ date=self._dt(2024, 3, 16), # wrong
161
+ currency="XOF",
162
+ )
163
+ # Force created_at to 2026 (simulate real server record time)
164
+ Transaction.objects.filter(id=tx.id).update(created_at=self._dt(2026, 5, 17))
165
+
166
+ compte = compute_compte_resultat(self.user, 2026)
167
+ self.assertGreaterEqual(compte.values_n.get("TA", Decimal("0")), Decimal("100.00"))
168
+
169
+ def test_transaction_serializer_clamps_bad_client_dates(self):
170
+ # Date far away (client clock wrong) should be clamped to "now" by default.
171
+ payload = {
172
+ "name": "Bad date tx",
173
+ "amount": "100.00",
174
+ "type": "income",
175
+ "category": "Ventes",
176
+ "currency": "XOF",
177
+ "date": self._dt(2024, 3, 16).isoformat().replace("+00:00", "Z"),
178
+ }
179
+
180
+ class _Req:
181
+ query_params = {}
182
+ user = self.user
183
+
184
+ serializer = TransactionSerializer(data=payload, context={"request": _Req()})
185
+ self.assertTrue(serializer.is_valid(), serializer.errors)
186
+ value = serializer.validated_data["date"]
187
+ now = timezone.now()
188
+ self.assertLessEqual(abs((value - now).days), 1)
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
  SyscohadaReportsDownloadView,
10
  analytics_overview, analytics_breakdown, analytics_kpi, analytics_activity,
11
  analytics_balance_history
@@ -19,6 +20,8 @@ router.register(r'budgets', BudgetViewSet, basename='budget')
19
  router.register(r'ads', AdViewSet, basename='ad')
20
  router.register(r'notifications', NotificationViewSet, basename='notification')
21
  router.register(r'support', SupportTicketViewSet, basename='support')
 
 
22
 
23
  urlpatterns = [
24
  # ===== AUTH =====
@@ -43,5 +46,6 @@ urlpatterns = [
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
  ]
 
6
  RegisterView, LoginView, ProfileView, ChangePasswordView,
7
  ProductViewSet, TransactionViewSet, BudgetViewSet, AdViewSet,
8
  NotificationViewSet, SupportTicketViewSet, VoiceCommandView, AIInsightsView,
9
+ SyscohadaCRMappingRuleViewSet, SyscohadaBilanBalanceViewSet, SyscohadaReportsPreviewView,
10
  SyscohadaReportsDownloadView,
11
  analytics_overview, analytics_breakdown, analytics_kpi, analytics_activity,
12
  analytics_balance_history
 
20
  router.register(r'ads', AdViewSet, basename='ad')
21
  router.register(r'notifications', NotificationViewSet, basename='notification')
22
  router.register(r'support', SupportTicketViewSet, basename='support')
23
+ router.register(r'syscohada/cr-rules', SyscohadaCRMappingRuleViewSet, basename='syscohada-cr-rule')
24
+ router.register(r'syscohada/bilan-balances', SyscohadaBilanBalanceViewSet, basename='syscohada-bilan-balance')
25
 
26
  urlpatterns = [
27
  # ===== AUTH =====
 
46
  path('ai-insights/', AIInsightsView.as_view(), name='ai-insights'),
47
 
48
  # ===== REPORTS (SYSCOHADA) =====
49
+ path('reports/syscohada/preview/', SyscohadaReportsPreviewView.as_view(), name='syscohada-preview'),
50
  path('reports/syscohada/download/', SyscohadaReportsDownloadView.as_view(), name='syscohada-download'),
51
  ]
api/views.py CHANGED
@@ -16,21 +16,30 @@ import json
16
  from django.http import HttpResponse
17
  from django.conf import settings
18
 
19
- from .models import Product, Transaction, Budget, Ad, Notification, SupportTicket, AIInsight
 
 
 
 
 
 
 
 
 
 
20
  from .serializers import (
21
  UserSerializer, RegisterSerializer, ChangePasswordSerializer,
22
  ProductSerializer, TransactionSerializer, TransactionSummarySerializer,
23
  BudgetSerializer, AdSerializer, OverviewAnalyticsSerializer,
24
  BreakdownAnalyticsSerializer, KPISerializer, ActivityAnalyticsSerializer,
25
- BalanceHistorySerializer, NotificationSerializer, SupportTicketSerializer
 
26
  )
27
- from .gemini_service import GeminiService
28
- from .groq_service import GroqService
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
 
@@ -586,22 +595,94 @@ class SupportTicketViewSet(viewsets.ModelViewSet):
586
  def perform_create(self, serializer):
587
  serializer.save(user=self.request.user)
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
 
590
  # ========== VOICE AI ==========
591
 
592
  class VoiceCommandView(APIView):
593
- """Traitement des commandes vocales via Gemini"""
594
  permission_classes = [IsAuthenticated]
595
 
596
  def post(self, request):
 
 
 
 
597
  audio_file = request.FILES.get('audio')
598
  text_command = request.data.get('text')
 
 
599
 
600
  if not audio_file and not text_command:
601
  return Response({'error': 'No audio file or text command provided'}, status=status.HTTP_400_BAD_REQUEST)
602
 
603
  try:
604
- service = GeminiService()
605
 
606
  # Fetch user products for context
607
  user_products = Product.objects.filter(user=request.user)
@@ -630,7 +711,7 @@ class VoiceCommandView(APIView):
630
  debug_url = f"{request.build_absolute_uri(settings.MEDIA_URL)}debug_voice/{debug_filename}"
631
  print(f"DEBUG AUDIO SAVED: {debug_path}")
632
 
633
- # Important: Réinitialiser le curseur après la sauvegarde pour Groq/Gemini
634
  audio_file.seek(0)
635
  except Exception as e:
636
  print(f"Error saving debug audio: {e}")
@@ -669,18 +750,24 @@ class VoiceCommandView(APIView):
669
  result = groq_service.process_text_command(transcription, context_products=products_list, model="llama-3.1-8b-instant")
670
 
671
  if not result:
672
- # Final resort: Gemini
673
- print("All Groq LLMs failed, falling back to Gemini...")
674
- gemini_service = GeminiService()
675
- result = gemini_service.process_text_command(transcription, context_products=products_list)
 
 
 
 
 
676
  else:
677
- # Fallback 2: Gemini's native voice processing
678
- print("All STT services failed, falling back to Gemini native voice command")
679
- audio_file.seek(0)
680
- audio_bytes = audio_file.read()
681
- mime_type = audio_file.content_type or 'audio/mp3'
682
- gemini_service = GeminiService()
683
- result = gemini_service.process_voice_command(audio_bytes, mime_type, context_products=products_list)
 
684
  else:
685
  # Direct text command processing with the same chain
686
  groq_service = GroqService()
@@ -692,32 +779,85 @@ class VoiceCommandView(APIView):
692
  result = groq_service.process_text_command(text_command, context_products=products_list, model="llama-3.1-8b-instant")
693
 
694
  if not result:
695
- print("All Groq LLMs failed, falling back to Gemini...")
696
- gemini_service = GeminiService()
697
- result = gemini_service.process_text_command(text_command, context_products=products_list)
 
 
 
 
 
698
 
699
  print(f"VoiceCommandView - Result Intent: {result.get('intent')}")
700
 
701
  if result.get('intent') == 'create_transaction':
702
  data = result.get('data', {})
703
  print(f"VoiceCommandView - Transaction Data: {data}")
704
-
705
- # Ensure date is a full datetime string for TransactionSerializer
706
- raw_date = data.get('date')
707
- final_datetime = timezone.now() # Already aware if USE_TZ=True
708
-
709
- if raw_date:
 
 
 
710
  try:
711
- # If AI gives YYYY-MM-DD, combine with current time
712
- parsed_date = datetime.strptime(raw_date, '%Y-%m-%d').date()
713
- # Make sure we create an aware datetime to avoid warnings
714
- naive_dt = datetime.combine(parsed_date, timezone.now().time())
715
- final_datetime = timezone.make_aware(naive_dt)
716
- except:
717
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
718
 
719
  # Prepare naming with fallback to transcription
720
- transcription_text = result.get('transcription', '')
721
  default_name = (transcription_text[:20] + '...') if len(transcription_text) > 20 else transcription_text
722
 
723
  transaction_data = {
@@ -801,10 +941,13 @@ class VoiceCommandView(APIView):
801
 
802
 
803
  class AIInsightsView(APIView):
804
- """Génération d'insights financiers via Gemini avec mise en mémoire en base de données"""
805
  permission_classes = [IsAuthenticated]
806
 
807
  def post(self, request):
 
 
 
808
  context_data = request.data.get('context', {})
809
 
810
  # Calculer un hash du contexte pour détecter les changements
@@ -821,8 +964,10 @@ class AIInsightsView(APIView):
821
  return Response({'insights': existing_insight.content, 'cached': True})
822
 
823
  try:
824
- service = GeminiService()
825
  insights = service.process_insights(context_data)
 
 
826
 
827
  # Sauvegarder le nouvel insight
828
  AIInsight.objects.create(
@@ -838,7 +983,18 @@ class AIInsightsView(APIView):
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):
 
16
  from django.http import HttpResponse
17
  from django.conf import settings
18
 
19
+ from .models import (
20
+ Product,
21
+ Transaction,
22
+ Budget,
23
+ Ad,
24
+ Notification,
25
+ SupportTicket,
26
+ AIInsight,
27
+ SyscohadaCRMappingRule,
28
+ SyscohadaBilanBalance,
29
+ )
30
  from .serializers import (
31
  UserSerializer, RegisterSerializer, ChangePasswordSerializer,
32
  ProductSerializer, TransactionSerializer, TransactionSummarySerializer,
33
  BudgetSerializer, AdSerializer, OverviewAnalyticsSerializer,
34
  BreakdownAnalyticsSerializer, KPISerializer, ActivityAnalyticsSerializer,
35
+ BalanceHistorySerializer, NotificationSerializer, SupportTicketSerializer,
36
+ SyscohadaCRMappingRuleSerializer, SyscohadaBilanBalanceSerializer,
37
  )
 
 
 
38
  import tempfile
39
  import os
40
  import io
41
  import zipfile
42
+ import re
43
 
44
  User = get_user_model()
45
 
 
595
  def perform_create(self, serializer):
596
  serializer.save(user=self.request.user)
597
 
598
+ class SyscohadaCRMappingRuleViewSet(viewsets.ModelViewSet):
599
+ """CRUD des règles de mapping SYSCOHADA (Compte de résultat)"""
600
+
601
+ serializer_class = SyscohadaCRMappingRuleSerializer
602
+ permission_classes = [IsAuthenticated]
603
+ filter_backends = [filters.SearchFilter, filters.OrderingFilter]
604
+ search_fields = ["ref", "category_pattern", "name_pattern"]
605
+ ordering_fields = ["priority", "updated_at", "created_at"]
606
+ ordering = ["priority", "-updated_at"]
607
+
608
+ def get_queryset(self):
609
+ return SyscohadaCRMappingRule.objects.filter(user=self.request.user).order_by("priority", "-updated_at", "-id")
610
+
611
+
612
+ class SyscohadaBilanBalanceViewSet(viewsets.ModelViewSet):
613
+ """CRUD des soldes SYSCOHADA (Bilan)"""
614
+
615
+ serializer_class = SyscohadaBilanBalanceSerializer
616
+ permission_classes = [IsAuthenticated]
617
+ filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
618
+ filterset_fields = ["year", "section"]
619
+ search_fields = ["ref", "note"]
620
+ ordering_fields = ["year", "section", "ref", "updated_at"]
621
+ ordering = ["-year", "section", "ref"]
622
+
623
+ def get_queryset(self):
624
+ return SyscohadaBilanBalance.objects.filter(user=self.request.user).order_by("-year", "section", "ref", "-id")
625
+
626
+
627
+ class SyscohadaReportsPreviewView(APIView):
628
+ """
629
+ Prévisualisation (debug/validation) des calculs SYSCOHADA.
630
+ Retourne: valeurs CR + bilan + liste des transactions non mappées.
631
+ """
632
+
633
+ permission_classes = [IsAuthenticated]
634
+
635
+ def get(self, request):
636
+ from .syscohada_reports import (
637
+ compute_compte_resultat,
638
+ compute_bilan_values,
639
+ )
640
+
641
+ try:
642
+ year = int(request.query_params.get("year") or timezone.now().year)
643
+ except ValueError:
644
+ return Response(
645
+ {"type": "validation_error", "errors": {"year": ["Invalid year."]}},
646
+ status=status.HTTP_400_BAD_REQUEST,
647
+ )
648
+
649
+ compte = compute_compte_resultat(request.user, year)
650
+ bilan = compute_bilan_values(request.user, year, compte)
651
+ return Response(
652
+ {
653
+ "year": year,
654
+ "compte_resultat": {
655
+ "values_n": {k: str(v) for k, v in compte.values_n.items()},
656
+ "values_n_1": {k: str(v) for k, v in compte.values_n_1.items()},
657
+ "unmapped_tx_ids_n": compte.unmapped_tx_ids_n,
658
+ "unmapped_tx_ids_n_1": compte.unmapped_tx_ids_n_1,
659
+ "resultat_net_n": str(compte.resultat_net_n),
660
+ },
661
+ "bilan": bilan,
662
+ }
663
+ )
664
+
665
 
666
  # ========== VOICE AI ==========
667
 
668
  class VoiceCommandView(APIView):
669
+ """Traitement des commandes vocales (STT + LLM) via Groq (et AssemblyAI STT en primaire)."""
670
  permission_classes = [IsAuthenticated]
671
 
672
  def post(self, request):
673
+ # Lazy imports to avoid hard dependency issues in environments where optional AI SDKs are not installed.
674
+ from .groq_service import GroqService
675
+ from .assemblyai_service import AssemblyAIService
676
+
677
  audio_file = request.FILES.get('audio')
678
  text_command = request.data.get('text')
679
+ client_datetime = request.data.get('client_datetime')
680
+ client_tz_offset_minutes = request.data.get('client_tz_offset_minutes')
681
 
682
  if not audio_file and not text_command:
683
  return Response({'error': 'No audio file or text command provided'}, status=status.HTTP_400_BAD_REQUEST)
684
 
685
  try:
 
686
 
687
  # Fetch user products for context
688
  user_products = Product.objects.filter(user=request.user)
 
711
  debug_url = f"{request.build_absolute_uri(settings.MEDIA_URL)}debug_voice/{debug_filename}"
712
  print(f"DEBUG AUDIO SAVED: {debug_path}")
713
 
714
+ # Important: Réinitialiser le curseur après la sauvegarde
715
  audio_file.seek(0)
716
  except Exception as e:
717
  print(f"Error saving debug audio: {e}")
 
750
  result = groq_service.process_text_command(transcription, context_products=products_list, model="llama-3.1-8b-instant")
751
 
752
  if not result:
753
+ return Response(
754
+ {
755
+ "status": "error",
756
+ "transcription": transcription,
757
+ "message": "Traitement LLM échoué (Groq).",
758
+ "debug_audio_url": debug_url,
759
+ },
760
+ status=status.HTTP_502_BAD_GATEWAY,
761
+ )
762
  else:
763
+ return Response(
764
+ {
765
+ "status": "error",
766
+ "message": "Transcription audio échouée (AssemblyAI + Groq).",
767
+ "debug_audio_url": debug_url,
768
+ },
769
+ status=status.HTTP_502_BAD_GATEWAY,
770
+ )
771
  else:
772
  # Direct text command processing with the same chain
773
  groq_service = GroqService()
 
779
  result = groq_service.process_text_command(text_command, context_products=products_list, model="llama-3.1-8b-instant")
780
 
781
  if not result:
782
+ return Response(
783
+ {
784
+ "status": "error",
785
+ "transcription": text_command,
786
+ "message": "Traitement LLM échoué (Groq).",
787
+ },
788
+ status=status.HTTP_502_BAD_GATEWAY,
789
+ )
790
 
791
  print(f"VoiceCommandView - Result Intent: {result.get('intent')}")
792
 
793
  if result.get('intent') == 'create_transaction':
794
  data = result.get('data', {})
795
  print(f"VoiceCommandView - Transaction Data: {data}")
796
+
797
+ transcription_text = result.get('transcription', '') or ''
798
+ final_datetime = timezone.now() # fallback
799
+
800
+ # Prefer client-side timestamp (real user time) over server time.
801
+ # This avoids timezone mismatches and prevents AI from ever influencing the saved date.
802
+ def parse_client_dt(value, offset_minutes):
803
+ if not value:
804
+ return None
805
  try:
806
+ s = str(value).strip()
807
+ # Support ISO strings ending with Z
808
+ if s.endswith('Z'):
809
+ s = s[:-1] + '+00:00'
810
+ dt = datetime.fromisoformat(s)
811
+
812
+ # If timezone-aware, normalize to UTC
813
+ if dt.tzinfo is not None:
814
+ return dt.astimezone(timezone.utc)
815
+
816
+ # If naive, use explicit offset minutes (JS: minutes to add to local to get UTC)
817
+ if offset_minutes is None or str(offset_minutes).strip() == '':
818
+ return timezone.make_aware(dt, timezone=timezone.utc)
819
+ off = int(offset_minutes)
820
+ dt_utc = dt + timedelta(minutes=off)
821
+ return timezone.make_aware(dt_utc, timezone=timezone.utc)
822
+ except Exception:
823
+ return None
824
+
825
+ client_dt = parse_client_dt(client_datetime, client_tz_offset_minutes)
826
+ if client_dt:
827
+ final_datetime = client_dt
828
+
829
+ # Deterministic date parsing (explicit user dates only), WITHOUT letting the LLM decide.
830
+ # Base reference: client_dt if provided, otherwise server now.
831
+ # Supported:
832
+ # - keywords: aujourd'hui, hier (FR)
833
+ # - YYYY-MM-DD
834
+ # - DD/MM/YYYY or DD-MM-YYYY
835
+ lower_t = transcription_text.lower()
836
+ base_dt = client_dt or timezone.now()
837
+
838
+ if "hier" in lower_t:
839
+ final_datetime = base_dt - timedelta(days=1)
840
+ elif "aujourd" in lower_t:
841
+ final_datetime = base_dt
842
+ else:
843
+ m_iso = re.search(r"\b(\d{4})-(\d{2})-(\d{2})\b", transcription_text)
844
+ m_fr = re.search(r"\b(\d{2})[/-](\d{2})[/-](\d{4})\b", transcription_text)
845
+ try:
846
+ if m_iso:
847
+ y, mo, d = int(m_iso.group(1)), int(m_iso.group(2)), int(m_iso.group(3))
848
+ parsed_date = datetime(y, mo, d).date()
849
+ # Keep time from base_dt to preserve "when" in user's local time
850
+ naive_dt = datetime.combine(parsed_date, base_dt.time())
851
+ final_datetime = timezone.make_aware(naive_dt) if naive_dt.tzinfo is None else naive_dt
852
+ elif m_fr:
853
+ d, mo, y = int(m_fr.group(1)), int(m_fr.group(2)), int(m_fr.group(3))
854
+ parsed_date = datetime(y, mo, d).date()
855
+ naive_dt = datetime.combine(parsed_date, base_dt.time())
856
+ final_datetime = timezone.make_aware(naive_dt) if naive_dt.tzinfo is None else naive_dt
857
+ except Exception:
858
+ final_datetime = base_dt
859
 
860
  # Prepare naming with fallback to transcription
 
861
  default_name = (transcription_text[:20] + '...') if len(transcription_text) > 20 else transcription_text
862
 
863
  transaction_data = {
 
941
 
942
 
943
  class AIInsightsView(APIView):
944
+ """Génération d'insights financiers via Groq avec mise en mémoire en base de données"""
945
  permission_classes = [IsAuthenticated]
946
 
947
  def post(self, request):
948
+ # Lazy import: Groq SDK may be optional depending on deployment.
949
+ from .groq_service import GroqService
950
+
951
  context_data = request.data.get('context', {})
952
 
953
  # Calculer un hash du contexte pour détecter les changements
 
964
  return Response({'insights': existing_insight.content, 'cached': True})
965
 
966
  try:
967
+ service = GroqService()
968
  insights = service.process_insights(context_data)
969
+ if not insights:
970
+ raise RuntimeError("Groq insights generation failed")
971
 
972
  # Sauvegarder le nouvel insight
973
  AIInsight.objects.create(
 
983
  if last_insight:
984
  return Response({'insights': last_insight.content, 'cached': True, 'error_fallback': str(e)})
985
 
986
+ # Fallback ultime (sans IA) pour ne pas casser le dashboard
987
+ return Response(
988
+ {
989
+ "insights": [
990
+ "Analyse des ventes en cours...",
991
+ "Vérification des dépenses...",
992
+ "Recommandation: surveillez vos postes récurrents et votre trésorerie.",
993
+ ],
994
+ "cached": False,
995
+ "error_fallback": str(e),
996
+ }
997
+ )
998
 
999
 
1000
  class SyscohadaReportsDownloadView(APIView):
backend/Documentation/syscohada_audit_2026_ginni_at_gmail_com.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SYSCOHADA Audit — ginni@gmail.com — 2026
2
+
3
+ ## Contexte
4
+ - User id: `5`
5
+ - Solde initial (`initial_balance`): `0.00`
6
+ - Transactions non mappées N: `0`
7
+ - Transactions non mappées N-1: `0`
8
+
9
+ ## Sources comparées
10
+ - CR CSV fourni: `—`
11
+ - Bilan CSV fourni: `—`
12
+
13
+ ## Résultat
14
+ - Aucun fichier fourni pour comparaison. Le rapport décrit uniquement les calculs attendus.
15
+
16
+ ## Calculs (DB)
17
+ - Total revenus N: `0`
18
+ - Total dépenses N: `0`
19
+ - Résultat net N (XI): `0`
20
+
21
+ ## Notes
22
+ - Les montants attendus sont calculés via `compute_compte_resultat()` + `generate_bilan_csv()`.
23
+ - Si les exports fournis sont à zéro, vérifier: année des transactions, `initial_balance`, et règles de mapping CR.