Spaces:
Sleeping
Sleeping
fix: correct transaction dates (2024 clock drift); improve validate_date; add fix_dates command
Browse files- Akompta/settings.py +26 -12
- Documentation/audit_inputs/bilan_syscohada_2026_ginni.csv +59 -0
- Documentation/audit_inputs/compte_resultat_syscohada_2026_ginni.csv +44 -0
- Documentation/demo_exports/bilan_syscohada_2026_demo.csv +58 -0
- Documentation/demo_exports/bilan_syscohada_2026_ginni_recalc.csv +58 -0
- Documentation/demo_exports/compte_resultat_syscohada_2026_demo.csv +43 -0
- Documentation/demo_exports/compte_resultat_syscohada_2026_ginni_recalc.csv +43 -0
- Documentation/syscohada_audit_2026_ginni_at_gmail_com.md +39 -0
- Documentation/syscohada_audit_2026_ginni_fixed_dates.md +23 -0
- Documentation/syscohada_demo_analysis_2026.md +64 -0
- api/admin.py +27 -2
- api/groq_service.py +64 -5
- api/management/commands/fix_dates.py +59 -0
- api/management/commands/syscohada_audit.py +257 -0
- api/migrations/0006_syscohada_mapping_and_balances.py +76 -0
- api/models.py +101 -1
- api/serializers.py +88 -2
- api/syscohada_reports.py +332 -48
- api/tests_syscohada.py +188 -0
- api/urls.py +4 -0
- api/views.py +195 -39
- backend/Documentation/syscohada_audit_2026_ginni_at_gmail_com.md +23 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 39 |
|
| 40 |
-
ALLOWED_HOSTS =
|
| 41 |
|
| 42 |
# CSRF Trusted Origins for Hugging Face and Frontend
|
| 43 |
-
CSRF_TRUSTED_ORIGINS =
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
| 47 |
)
|
| 48 |
|
| 49 |
|
|
@@ -215,10 +228,11 @@ SIMPLE_JWT = {
|
|
| 215 |
|
| 216 |
|
| 217 |
# CORS Configuration
|
| 218 |
-
CORS_ALLOWED_ORIGINS =
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 37 |
"""
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 48 |
-
if any(k in haystack for k in ["
|
| 49 |
return "TD"
|
| 50 |
-
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
else:
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
|
|
|
| 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 |
-
|
| 696 |
-
|
| 697 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
|
|
|
|
|
|
|
|
|
| 710 |
try:
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|