File size: 23,027 Bytes
9d855fa
 
 
 
 
 
 
 
 
 
 
 
833c388
9d855fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833c388
9d855fa
833c388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d855fa
 
 
 
 
 
833c388
 
 
9d855fa
833c388
 
 
9d855fa
 
833c388
9d855fa
833c388
9d855fa
833c388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d855fa
833c388
9d855fa
833c388
9d855fa
833c388
9d855fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833c388
 
9d855fa
 
 
 
 
 
 
 
 
833c388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d855fa
 
 
833c388
 
9d855fa
 
 
 
833c388
 
 
 
 
 
 
 
 
 
9d855fa
 
 
 
 
 
 
 
 
 
833c388
 
 
 
 
 
 
 
 
9d855fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833c388
 
9d855fa
 
 
833c388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d855fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833c388
9d855fa
 
 
 
833c388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d855fa
833c388
 
 
9d855fa
 
 
 
 
833c388
 
 
9d855fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833c388
9d855fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
from __future__ import annotations

import csv
import io
import json
import re
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any

from django.db import models
from django.utils import timezone

from .models import Transaction, User


TEMPLATES_DIR = Path(__file__).resolve().parent / "syscohada_templates"


def _load_template_json(filename: str) -> Any:
    with (TEMPLATES_DIR / filename).open("r", encoding="utf-8") as f:
        return json.load(f)


def _year_bounds(year: int) -> tuple[datetime, datetime]:
    start = timezone.make_aware(datetime(year, 1, 1, 0, 0, 0))
    end = timezone.make_aware(datetime(year + 1, 1, 1, 0, 0, 0))
    return start, end


def _normalize_text(value: str | None) -> str:
    return (value or "").strip().lower()


def _pick_effective_datetime(tx: Transaction) -> datetime:
    """
    Choisit la date "effective" d'une transaction pour les rapports.

    Contexte: certains clients envoient une `date` incorrecte (ex: horloge appareil en 2024)
    alors que `created_at` (serveur) est correcte (2026).

    Règle:
    - si l'écart absolu entre `date` et `created_at` dépasse 180 jours,
      on utilise `created_at` comme date effective.
    - sinon on conserve `date`.
    """
    try:
        created_at = tx.created_at
        tx_date = tx.date
        if created_at and tx_date:
            delta_days = abs((tx_date - created_at).days)
            if delta_days > 180:
                return created_at
        return tx_date
    except Exception:
        return tx.date


def _in_year_bounds(tx: Transaction, start: datetime, end: datetime) -> bool:
    eff = _pick_effective_datetime(tx)
    return start <= eff < end


def _map_transaction_to_cr_ref_from_user_rules(user: User, tx: Transaction) -> tuple[str | None, bool]:
    """
    Map via règles utilisateur (priorité) si disponibles.
    Retourne: (ref|None, matched_via_rule)
    """
    from .models import SyscohadaCRMappingRule

    try:
        rules = SyscohadaCRMappingRule.objects.filter(user=user, is_active=True).order_by("priority", "-updated_at", "-id")
    except Exception:
        # Si les migrations ne sont pas appliquées ou table absente, ignorer les règles
        return None, False
    if not rules.exists():
        return None, False

    category = _normalize_text(getattr(tx, "category", ""))
    name = _normalize_text(getattr(tx, "name", ""))

    for rule in rules:
        if rule.tx_type and rule.tx_type != tx.type:
            continue

        cat_pat = (rule.category_pattern or "").strip()
        name_pat = (rule.name_pattern or "").strip()

        # Wildcard rule (no patterns) is allowed for explicit fallbacks
        if rule.match_mode == "contains":
            ok_cat = True if not cat_pat else _normalize_text(cat_pat) in category
            ok_name = True if not name_pat else _normalize_text(name_pat) in name
            if ok_cat and ok_name:
                return rule.ref, True
        else:  # regex
            ok_cat = True
            ok_name = True
            try:
                if cat_pat:
                    ok_cat = re.search(cat_pat, category, flags=re.IGNORECASE) is not None
                if name_pat:
                    ok_name = re.search(name_pat, name, flags=re.IGNORECASE) is not None
            except re.error:
                # Si regex invalide: ignorer la règle (robustesse)
                continue

            if ok_cat and ok_name:
                return rule.ref, True

    return None, False


def _map_transaction_to_cr_ref_default(tx: Transaction) -> str | None:
    """
    Mapping "par défaut" (sans règles utilisateur) basé sur mots-clés.
    Retourne None si aucune catégorie n'est reconnue (=> transaction non mappée).
    """
    category = _normalize_text(getattr(tx, "category", ""))
    name = _normalize_text(getattr(tx, "name", ""))
    haystack = f"{category} {name}".strip()

    if tx.type == "income":
        if any(k in haystack for k in ["service", "prestation", "consult", "honoraire"]):
            return "TC"  # travaux / services vendus
        if any(k in haystack for k in ["accessoire"]):
            return "TD"
        if any(k in haystack for k in ["vente", "ventes", "marchandise", "produit", "produits"]):
            return "TA"
        return None

    # expense
    if any(k in haystack for k in ["achat", "achats", "marchandise", "appro", "approvisionnement", "fournisseur"]):
        return "RA"
    if any(k in haystack for k in ["transport", "taxi", "bus", "essence", "carburant", "livraison", "deplacement", "déplacement"]):
        return "RG"
    if any(
        k in haystack
        for k in [
            "loyer",
            "internet",
            "eau",
            "electric",
            "électric",
            "telephone",
            "téléphone",
            "prestataire",
            "maintenance",
            "marketing",
            "publicit",
            "publicité",
            "pub",
            "assurance",
        ]
    ):
        return "RH"
    if any(k in haystack for k in ["impot", "impôt", "taxe", "douane", "etat", "état", "tva"]):
        return "RI"
    if any(k in haystack for k in ["salaire", "salaires", "personnel", "paie", "payroll", "prime"]):
        return "RK"
    return None


_CR_TOKEN_RE = re.compile(r"([A-Z]{1,2})|([+-])")


def _eval_cr_formula(formula: str, values: dict[str, Decimal]) -> Decimal:
    """
    Evaluate formulas like: "XB-RA+RB+TE-RE" using Decimal arithmetic.
    Supports only refs (A-Z, 1-2 chars) and + / -.
    """
    tokens = [m.group(0) for m in _CR_TOKEN_RE.finditer(formula.replace(" ", ""))]
    if not tokens:
        return Decimal("0")

    total = Decimal("0")
    op = "+"
    for tok in tokens:
        if tok in {"+", "-"}:
            op = tok
            continue
        value = values.get(tok, Decimal("0"))
        total = total + value if op == "+" else total - value
    return total


@dataclass(frozen=True)
class CompteResultatComputed:
    year: int
    values_n: dict[str, Decimal]
    values_n_1: dict[str, Decimal]
    resultat_net_n: Decimal
    total_income_n: Decimal
    total_expense_n: Decimal
    total_income_n_1: Decimal
    total_expense_n_1: Decimal
    unmapped_tx_ids_n: list[int]
    unmapped_tx_ids_n_1: list[int]


def compute_compte_resultat(user: User, year: int) -> CompteResultatComputed:
    structure = _load_template_json("compte_resultat_structure.json")
    lignes: list[dict[str, Any]] = structure["lignes"]

    start_n, end_n = _year_bounds(year)
    start_n_1, end_n_1 = _year_bounds(year - 1)

    # Important: include both date- and created_at-based windows, then decide per-row
    # to handle device clock issues (date far from created_at).
    tx_candidates_n = Transaction.objects.filter(
        user=user,
    ).filter(
        (models.Q(date__gte=start_n, date__lt=end_n))
        | (models.Q(created_at__gte=start_n, created_at__lt=end_n))
    ).only("id", "amount", "type", "category", "name", "date", "created_at")

    tx_candidates_n_1 = Transaction.objects.filter(
        user=user,
    ).filter(
        (models.Q(date__gte=start_n_1, date__lt=end_n_1))
        | (models.Q(created_at__gte=start_n_1, created_at__lt=end_n_1))
    ).only("id", "amount", "type", "category", "name", "date", "created_at")

    tx_n = [tx for tx in tx_candidates_n if _in_year_bounds(tx, start_n, end_n)]
    tx_n_1 = [tx for tx in tx_candidates_n_1 if _in_year_bounds(tx, start_n_1, end_n_1)]

    values_n: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in lignes}
    values_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in lignes}
    unmapped_tx_ids_n: list[int] = []
    unmapped_tx_ids_n_1: list[int] = []

    total_income_n = Decimal("0")
    total_expense_n = Decimal("0")
    for tx in tx_n:
        ref, matched_via_rule = _map_transaction_to_cr_ref_from_user_rules(user, tx)
        if not ref:
            ref = _map_transaction_to_cr_ref_default(tx)
            if not ref:
                unmapped_tx_ids_n.append(tx.id)
                ref = "RJ"  # Divers / fallback (si présent dans le template)
        if ref not in values_n:
            # ref inconnue => fallback RJ + marquer unmapped
            unmapped_tx_ids_n.append(tx.id)
            ref = "RJ"
        amount = Decimal(tx.amount)
        values_n[ref] = values_n.get(ref, Decimal("0")) + amount
        if tx.type == "income":
            total_income_n += amount
        else:
            total_expense_n += amount

    total_income_n_1 = Decimal("0")
    total_expense_n_1 = Decimal("0")
    for tx in tx_n_1:
        ref, matched_via_rule = _map_transaction_to_cr_ref_from_user_rules(user, tx)
        if not ref:
            ref = _map_transaction_to_cr_ref_default(tx)
            if not ref:
                unmapped_tx_ids_n_1.append(tx.id)
                ref = "RJ"
        if ref not in values_n_1:
            unmapped_tx_ids_n_1.append(tx.id)
            ref = "RJ"
        amount = Decimal(tx.amount)
        values_n_1[ref] = values_n_1.get(ref, Decimal("0")) + amount
        if tx.type == "income":
            total_income_n_1 += amount
        else:
            total_expense_n_1 += amount

    # Compute total lines in order (formulas reference previous totals, order in structure matters)
    for item in lignes:
        if not item.get("is_total"):
            continue
        formula = item.get("formula") or ""
        values_n[item["ref"]] = _eval_cr_formula(formula, values_n)
        values_n_1[item["ref"]] = _eval_cr_formula(formula, values_n_1)

    # For MVP, use the computed XI if available; fallback to income-expense.
    resultat_net_n = values_n.get("XI")
    if resultat_net_n is None:
        resultat_net_n = total_income_n - total_expense_n

    return CompteResultatComputed(
        year=year,
        values_n=values_n,
        values_n_1=values_n_1,
        resultat_net_n=resultat_net_n,
        total_income_n=total_income_n,
        total_expense_n=total_expense_n,
        total_income_n_1=total_income_n_1,
        total_expense_n_1=total_expense_n_1,
        unmapped_tx_ids_n=unmapped_tx_ids_n,
        unmapped_tx_ids_n_1=unmapped_tx_ids_n_1,
    )


def compute_bilan_values(user: User, year: int, compte: CompteResultatComputed) -> dict[str, object]:
    """
    Calcule les valeurs du bilan (Actif/Passif) en combinant:
    - soldes saisis/importés (SyscohadaBilanBalance)
    - auto-calc: BS (trésorerie) et CJ (résultat net)

    Retourne une structure JSON-friendly utilisable par preview + export.
    """
    from .models import SyscohadaBilanBalance

    structure = _load_template_json("bilan_structure.json")
    actif: list[dict[str, Any]] = structure["actif"]
    passif: list[dict[str, Any]] = structure["passif"]

    # Load user balances for N and N-1 (support colonnes N et N-1)
    try:
        # Force evaluation inside try: sqlite can raise "no such table" only at iteration time.
        balances_n = list(SyscohadaBilanBalance.objects.filter(user=user, year=year))
        balances_n_1 = list(SyscohadaBilanBalance.objects.filter(user=user, year=year - 1))
    except Exception:
        # Table absente / migrations non appliquées: fallback sans soldes saisis
        balances_n = []
        balances_n_1 = []

    def split_balances(qs):
        actif_bal: dict[str, dict[str, Decimal]] = {}
        passif_bal: dict[str, Decimal] = {}
        for b in qs:
            if b.section == "ACTIF":
                actif_bal[b.ref] = {
                    "brut": Decimal(b.brut or 0),
                    "amort": Decimal(b.amort or 0),
                }
            else:
                passif_bal[b.ref] = Decimal(b.net or 0)
        return actif_bal, passif_bal

    actif_bal_n, passif_bal_n = split_balances(balances_n)
    actif_bal_n_1, passif_bal_n_1 = split_balances(balances_n_1)

    # Auto-calc BS (cash) for N and N-1
    cash_n = user.initial_balance + compte.total_income_n - compte.total_expense_n
    cash_n_1 = user.initial_balance + compte.total_income_n_1 - compte.total_expense_n_1

    actif_bal_n["BS"] = {"brut": Decimal(cash_n), "amort": Decimal("0")}
    actif_bal_n_1["BS"] = {"brut": Decimal(cash_n_1), "amort": Decimal("0")}

    # Auto-calc CJ (resultat net) in passif (if template contains CJ)
    passif_bal_n["CJ"] = Decimal(compte.resultat_net_n)
    # For N-1 we use computed XI if present; otherwise 0
    passif_bal_n_1["CJ"] = Decimal(compte.values_n_1.get("XI", Decimal("0")))

    # Compute totals (validation): TOTAL ACTIF (BZ) vs TOTAL PASSIF (DZ)
    def compute_totals_for(
        actif_bal: dict[str, dict[str, Decimal]],
        passif_bal: dict[str, Decimal],
        cash: Decimal,
        resultat: Decimal,
    ) -> dict[str, str]:
        brut: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
        amort: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
        for ref, v in actif_bal.items():
            brut[ref] = Decimal(v.get("brut", 0))
            amort[ref] = Decimal(v.get("amort", 0))

        brut["BS"] = Decimal(cash)
        amort["BS"] = Decimal("0")

        net_passif: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
        for ref, v in passif_bal.items():
            net_passif[ref] = Decimal(v)
        net_passif["CJ"] = Decimal(resultat)

        def net_for(ref: str) -> Decimal:
            return brut.get(ref, Decimal("0")) - amort.get(ref, Decimal("0"))

        # Compute header subtotals (stable SYSCOHADA groupings)
        header_groups = {
            "AD": ["AE", "AF", "AG", "AH"],
            "AI": ["AJ", "AK", "AL", "AM", "AN", "AP"],
            "AQ": ["AR", "AS"],
            "BG": ["BH", "BI", "BJ"],
        }
        for header_ref, children in header_groups.items():
            brut[header_ref] = sum((brut.get(c, Decimal("0")) for c in children), Decimal("0"))
            amort[header_ref] = sum((amort.get(c, Decimal("0")) for c in children), Decimal("0"))

        # Compute totals based on formulas (only '+' is expected in these bilan totals)
        def parse_sum_formula(formula: str) -> list[str]:
            return [part.strip() for part in formula.split("+") if part.strip()]

        for item in actif:
            if not item.get("is_total"):
                continue
            parts = parse_sum_formula(item.get("formula", ""))
            brut[item["ref"]] = sum((brut.get(p, Decimal("0")) for p in parts), Decimal("0"))
            amort[item["ref"]] = sum((amort.get(p, Decimal("0")) for p in parts), Decimal("0"))

        passif_meta: dict[str, dict[str, Any]] = {item["ref"]: item for item in passif}

        def signed_passif_value(ref: str) -> Decimal:
            val = net_passif.get(ref, Decimal("0"))
            meta = passif_meta.get(ref, {})
            if meta.get("is_negative"):
                return -val
            return val

        for item in passif:
            if not item.get("is_total"):
                continue
            parts = parse_sum_formula(item.get("formula", ""))
            net_passif[item["ref"]] = sum((signed_passif_value(p) for p in parts), Decimal("0"))

        total_actif = net_for("BZ")
        total_passif = signed_passif_value("DZ")
        delta = total_actif - total_passif
        return {
            "total_actif": str(total_actif),
            "total_passif": str(total_passif),
            "delta": str(delta),
        }

    totals_n = compute_totals_for(actif_bal_n, passif_bal_n, cash_n, compte.resultat_net_n)
    totals_n_1 = compute_totals_for(actif_bal_n_1, passif_bal_n_1, cash_n_1, passif_bal_n_1["CJ"])

    return {
        "year": year,
        "auto": {
            "BS": {"net_n": str(cash_n), "net_n_1": str(cash_n_1)},
            "CJ": {"net_n": str(compte.resultat_net_n), "net_n_1": str(passif_bal_n_1["CJ"])},
        },
        "totals": {"n": totals_n, "n_1": totals_n_1},
        "actif": {ref: {"brut": str(v["brut"]), "amort": str(v["amort"])} for ref, v in actif_bal_n.items()},
        "passif": {ref: str(v) for ref, v in passif_bal_n.items()},
        "actif_n_1": {ref: {"brut": str(v["brut"]), "amort": str(v["amort"])} for ref, v in actif_bal_n_1.items()},
        "passif_n_1": {ref: str(v) for ref, v in passif_bal_n_1.items()},
    }


def generate_compte_resultat_csv(compte: CompteResultatComputed) -> bytes:
    structure = _load_template_json("compte_resultat_structure.json")
    lignes: list[dict[str, Any]] = structure["lignes"]

    out = io.StringIO()
    writer = csv.writer(out)
    writer.writerow(["REF", "LIBELLES", "NUMERO DE COMPTES", "MONTANT_N", "MONTANT_N_1"])
    for item in lignes:
        ref = item["ref"]
        writer.writerow(
            [
                ref,
                item.get("libelle", ""),
                item.get("compte", ""),
                str(compte.values_n.get(ref, Decimal("0"))),
                str(compte.values_n_1.get(ref, Decimal("0"))),
            ]
        )
    return out.getvalue().encode("utf-8")


def generate_bilan_csv(user: User, compte: CompteResultatComputed) -> bytes:
    structure = _load_template_json("bilan_structure.json")
    actif: list[dict[str, Any]] = structure["actif"]
    passif: list[dict[str, Any]] = structure["passif"]
    from .models import SyscohadaBilanBalance

    # Actif values stored by ref: BRUT, AMORT, NET_N, NET_N_1
    brut: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
    amort: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
    brut_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}
    amort_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in actif}

    # Passif values stored by ref: NET_N, NET_N_1
    passif_meta: dict[str, dict[str, Any]] = {item["ref"]: item for item in passif}
    net_passif_n: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}
    net_passif_n_1: dict[str, Decimal] = {item["ref"]: Decimal("0") for item in passif}

    # 1) Load user-provided balances (N and N-1) (optional)
    try:
        # Force evaluation inside try: sqlite can raise "no such table" only at iteration time.
        balances_n = list(SyscohadaBilanBalance.objects.filter(user=user, year=compte.year))
        balances_n_1 = list(SyscohadaBilanBalance.objects.filter(user=user, year=compte.year - 1))
    except Exception:
        balances_n = []
        balances_n_1 = []

    for b in balances_n:
        if b.section == "ACTIF":
            brut[b.ref] = Decimal(b.brut or 0)
            amort[b.ref] = Decimal(b.amort or 0)
        else:
            net_passif_n[b.ref] = Decimal(b.net or 0)

    for b in balances_n_1:
        if b.section == "ACTIF":
            brut_n_1[b.ref] = Decimal(b.brut or 0)
            amort_n_1[b.ref] = Decimal(b.amort or 0)
        else:
            net_passif_n_1[b.ref] = Decimal(b.net or 0)

    # 2) Auto-calc: cash (BS) + result (CJ)
    cash_n = user.initial_balance + compte.total_income_n - compte.total_expense_n
    cash_n_1 = user.initial_balance + compte.total_income_n_1 - compte.total_expense_n_1
    brut["BS"] = Decimal(cash_n)
    amort["BS"] = Decimal("0")
    brut_n_1["BS"] = Decimal(cash_n_1)
    amort_n_1["BS"] = Decimal("0")

    net_passif_n["CJ"] = Decimal(compte.resultat_net_n)
    net_passif_n_1["CJ"] = Decimal(compte.values_n_1.get("XI", Decimal("0")))

    def net_for(ref: str) -> Decimal:
        return brut.get(ref, Decimal("0")) - amort.get(ref, Decimal("0"))

    def net_for_n_1(ref: str) -> Decimal:
        return brut_n_1.get(ref, Decimal("0")) - amort_n_1.get(ref, Decimal("0"))

    # Compute header subtotals (stable SYSCOHADA groupings)
    header_groups = {
        "AD": ["AE", "AF", "AG", "AH"],
        "AI": ["AJ", "AK", "AL", "AM", "AN", "AP"],
        "AQ": ["AR", "AS"],
        "BG": ["BH", "BI", "BJ"],
    }
    for header_ref, children in header_groups.items():
        brut[header_ref] = sum((brut.get(c, Decimal("0")) for c in children), Decimal("0"))
        amort[header_ref] = sum((amort.get(c, Decimal("0")) for c in children), Decimal("0"))
        brut_n_1[header_ref] = sum((brut_n_1.get(c, Decimal("0")) for c in children), Decimal("0"))
        amort_n_1[header_ref] = sum((amort_n_1.get(c, Decimal("0")) for c in children), Decimal("0"))

    # Compute totals based on formulas (only '+' is expected in these bilan totals)
    def parse_bilan_sum_formula(formula: str) -> list[str]:
        return [part.strip() for part in formula.split("+") if part.strip()]

    for item in actif:
        if not item.get("is_total"):
            continue
        parts = parse_bilan_sum_formula(item.get("formula", ""))
        brut[item["ref"]] = sum((brut.get(p, Decimal("0")) for p in parts), Decimal("0"))
        amort[item["ref"]] = sum((amort.get(p, Decimal("0")) for p in parts), Decimal("0"))
        brut_n_1[item["ref"]] = sum((brut_n_1.get(p, Decimal("0")) for p in parts), Decimal("0"))
        amort_n_1[item["ref"]] = sum((amort_n_1.get(p, Decimal("0")) for p in parts), Decimal("0"))

    # Notes: CA (capital) et autres postes doivent venir des soldes saisis/importés.

    def signed_passif_value(values: dict[str, Decimal], ref: str) -> Decimal:
        val = values.get(ref, Decimal("0"))
        meta = passif_meta.get(ref, {})
        if meta.get("is_negative"):
            return -val
        return val

    def eval_passif_formula(values: dict[str, Decimal], formula: str) -> Decimal:
        parts = parse_bilan_sum_formula(formula)
        return sum((signed_passif_value(values, p) for p in parts), Decimal("0"))

    for item in passif:
        if not item.get("is_total"):
            continue
        ref = item["ref"]
        net_passif_n[ref] = eval_passif_formula(net_passif_n, item.get("formula", ""))
        net_passif_n_1[ref] = eval_passif_formula(net_passif_n_1, item.get("formula", ""))

    # Build a single CSV containing both sections.
    out = io.StringIO()
    writer = csv.writer(out)
    writer.writerow(["SECTION", "REF", "LIBELLE", "NOTE", "BRUT", "AMORT/DEPREC", "NET_N", "NET_N_1"])

    for item in actif:
        ref = item["ref"]
        writer.writerow(
            [
                "ACTIF",
                ref,
                item.get("libelle", ""),
                item.get("note", ""),
                str(brut.get(ref, Decimal("0"))),
                str(amort.get(ref, Decimal("0"))),
                str(net_for(ref)),
                str(net_for_n_1(ref)),
            ]
        )

    for item in passif:
        ref = item["ref"]
        writer.writerow(
            [
                "PASSIF",
                ref,
                item.get("libelle", ""),
                item.get("note", ""),
                "",
                "",
                str(signed_passif_value(net_passif_n, ref)),
                str(signed_passif_value(net_passif_n_1, ref)),
            ]
        )

    return out.getvalue().encode("utf-8")