Application web locale (FastAPI + SQLite + React SPA) pour la gestion des immobilisations fiscales d'une PME québécoise (T2 / CO-17). ─── Modèle de données ─────────────────────────────────────────────────────── • Deux niveaux : PARENT (immobilisation fiscale) + PIÈCE (inventaire physique) • Trois vues par actif : Propriétaire, CPA, Auditeur • Porteur d'étiquette d'assemblage (unique par immobilisation) • Piste d'audit journalisée sur chaque modification ─── Mécanique fiscale ─────────────────────────────────────────────────────── • Pool DPA par catégorie (Cat. 8, 10, 12, 46, 50) — amortissement dégressif • Règle de la demi-année paramétrable (coeff. 0..1 par exercice) • Perte finale et récupération d'amortissement calculées automatiquement • Annexe 8 / Schedule 8 multi-catégories (JSON + CSV téléchargeable) • Rapport fiscal HTML imprimable — lignes Schedule 1 (T2 : 104/107/410/414 ; CO-17 : 130/135/150/155) • Export immobilisations par catégorie (JSON + CSV) ─── Fonctionnalités ───────────────────────────────────────────────────────── • CRUD complet : parents et pièces, rattachement/détachement (libre/stock/rebut) • Écriture comptable générée au détachement • Registre de pool DPA multi-exercices avec override manuel • Sauvegarde/restauration (SQLite native backup, horodatée) • Factures jointes aux pièces (PDF/JPG/PNG, max 20 Mo, UUID + regex) • UI mobile-first : bottom nav, FAB, bottom sheet, safe-area iPhone • Guide utilisateur complet in-app (aide.html, 11 sections) • Données de démonstration riches (6 immos, 5 catégories, perte finale + récupération incluses) ─── Qualité ───────────────────────────────────────────────────────────────── • 50 tests pytest — 100 % verts (calculs DPA, export, backup, factures, seed) • Lancement en 1 commande : ./lancer.sh (Mac/Linux) ou lancer.bat (Windows) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
171 lines
7.8 KiB
Python
171 lines
7.8 KiB
Python
"""
|
|
Registre de catégorie (pool fiscal) — couche au-dessus de l'inventaire.
|
|
|
|
L'inventaire physique (assets : parents/pièces) et le pool fiscal sont
|
|
SÉPARÉS. Le pool agrège des flux financiers par année et par catégorie DPA,
|
|
indépendamment du serveur physique auquel une dépense est rattachée.
|
|
|
|
Table `cca_pool_years` :
|
|
- year, cca_class_id : clé (une ligne par année et catégorie)
|
|
- additions_override : ajouts saisis manuellement (NULL = dérivé)
|
|
- dispositions : produit des dispositions de l'année
|
|
- adjustment_coeff : coeff 0..1 sur ajouts nets (0.5 = demi-année)
|
|
- pool_physically_empty : 1 si plus aucun actif physique (perte finale)
|
|
- opening_ucc_override : FNACC d'ouverture initiale (1re année)
|
|
|
|
Les AJOUTS d'une année peuvent être dérivés automatiquement de l'inventaire
|
|
(somme des coûts des acquisitions dont l'année d'acquisition = year) ou
|
|
saisis manuellement (override). Les dispositions et le coefficient sont
|
|
toujours saisis par l'usager.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from decimal import Decimal
|
|
from threading import Lock
|
|
|
|
from .cca_pool import YearInput, compute_pool_timeline
|
|
|
|
|
|
class CcaRegistry:
|
|
def __init__(self, conn: sqlite3.Connection, store, lock: Lock) -> None:
|
|
self._conn = conn
|
|
self._store = store
|
|
self._lock = lock
|
|
self._init_schema()
|
|
|
|
def _init_schema(self) -> None:
|
|
self._conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS cca_pool_years (
|
|
year INTEGER NOT NULL,
|
|
cca_class_id INTEGER NOT NULL,
|
|
additions_override REAL,
|
|
dispositions REAL NOT NULL DEFAULT 0,
|
|
adjustment_coeff REAL NOT NULL DEFAULT 0.5,
|
|
pool_physically_empty INTEGER NOT NULL DEFAULT 0,
|
|
opening_ucc_override REAL,
|
|
PRIMARY KEY (year, cca_class_id)
|
|
)
|
|
""")
|
|
self._conn.commit()
|
|
|
|
# ---- Dérivation des ajouts depuis l'inventaire ----
|
|
def _derived_additions(self, year: int, cca_class_id: int) -> Decimal:
|
|
"""Somme des coûts des acquisitions de l'année pour cette catégorie.
|
|
On compte : le coût des PIÈCES dont acquisition_date tombe dans l'année
|
|
(qu'elles soient rattachées ou libres) + les PARENTS sans pièce (coût
|
|
propre). Un parent avec pièces n'est pas compté en double : ses pièces
|
|
portent déjà le coût."""
|
|
total = Decimal("0")
|
|
for a in self._store.all():
|
|
cca = a["view_cpa"]["cca_class"]["id"]
|
|
if cca != cca_class_id:
|
|
continue
|
|
acq = (a["view_cpa"].get("acquisition_date") or "")[:4]
|
|
if acq != str(year):
|
|
continue
|
|
if a["type"] == "piece":
|
|
total += Decimal(str(a["view_cpa"]["capitalizable_base_cad"]))
|
|
elif a["type"] == "parent":
|
|
# Parent sans pièce : on prend son coût propre (sinon 0, ses
|
|
# pièces sont comptées séparément).
|
|
if a["view_admin"].get("pieces_count", 0) == 0:
|
|
total += Decimal(str(a["view_cpa"]["capitalizable_base_cad"]))
|
|
return total
|
|
|
|
def _physical_count(self, cca_class_id: int) -> int:
|
|
"""Nombre d'actifs physiques actifs dans la catégorie (parents + pièces
|
|
non au rebut)."""
|
|
n = 0
|
|
for a in self._store.all():
|
|
if a["view_cpa"]["cca_class"]["id"] != cca_class_id:
|
|
continue
|
|
if a["view_admin"].get("operational_status") == "Rebut":
|
|
continue
|
|
n += 1
|
|
return n
|
|
|
|
# ---- CRUD années ----
|
|
def upsert_year(self, year: int, cca_class_id: int = 50, **kw) -> None:
|
|
with self._lock:
|
|
existing = self._conn.execute(
|
|
"SELECT 1 FROM cca_pool_years WHERE year=? AND cca_class_id=?",
|
|
(year, cca_class_id)).fetchone()
|
|
fields = {
|
|
"additions_override": kw.get("additions_override"),
|
|
"dispositions": float(kw.get("dispositions", 0) or 0),
|
|
"adjustment_coeff": float(kw.get("adjustment_coeff", 0.5) or 0.5),
|
|
"pool_physically_empty": 1 if kw.get("pool_physically_empty") else 0,
|
|
"opening_ucc_override": kw.get("opening_ucc_override"),
|
|
}
|
|
if existing:
|
|
self._conn.execute("""
|
|
UPDATE cca_pool_years SET additions_override=?, dispositions=?,
|
|
adjustment_coeff=?, pool_physically_empty=?, opening_ucc_override=?
|
|
WHERE year=? AND cca_class_id=?""",
|
|
(fields["additions_override"], fields["dispositions"],
|
|
fields["adjustment_coeff"], fields["pool_physically_empty"],
|
|
fields["opening_ucc_override"], year, cca_class_id))
|
|
else:
|
|
self._conn.execute("""
|
|
INSERT INTO cca_pool_years (year, cca_class_id, additions_override,
|
|
dispositions, adjustment_coeff, pool_physically_empty, opening_ucc_override)
|
|
VALUES (?,?,?,?,?,?,?)""",
|
|
(year, cca_class_id, fields["additions_override"], fields["dispositions"],
|
|
fields["adjustment_coeff"], fields["pool_physically_empty"],
|
|
fields["opening_ucc_override"]))
|
|
self._conn.commit()
|
|
|
|
def delete_year(self, year: int, cca_class_id: int = 50) -> None:
|
|
with self._lock:
|
|
self._conn.execute("DELETE FROM cca_pool_years WHERE year=? AND cca_class_id=?",
|
|
(year, cca_class_id))
|
|
self._conn.commit()
|
|
|
|
def _year_rows(self, cca_class_id: int) -> list[sqlite3.Row]:
|
|
cur = self._conn.execute(
|
|
"SELECT * FROM cca_pool_years WHERE cca_class_id=? ORDER BY year",
|
|
(cca_class_id,))
|
|
return cur.fetchall()
|
|
|
|
# ---- Calcul du registre ----
|
|
def compute(self, cca_class_id: int = 50, cca_rate: Decimal = Decimal("0.55")) -> dict:
|
|
"""Calcule la timeline du pool pour une catégorie.
|
|
Les ajouts sont dérivés de l'inventaire sauf si un override est saisi."""
|
|
rows = self._year_rows(cca_class_id)
|
|
|
|
# Si aucune année déclarée, on déduit les années présentes dans l'inventaire.
|
|
if not rows:
|
|
years_seen = set()
|
|
for a in self._store.all():
|
|
if a["view_cpa"]["cca_class"]["id"] == cca_class_id:
|
|
acq = (a["view_cpa"].get("acquisition_date") or "")[:4]
|
|
if acq.isdigit():
|
|
years_seen.add(int(acq))
|
|
for y in years_seen:
|
|
self.upsert_year(y, cca_class_id)
|
|
rows = self._year_rows(cca_class_id)
|
|
|
|
opening = Decimal("0")
|
|
year_inputs = []
|
|
for r in rows:
|
|
add = (Decimal(str(r["additions_override"]))
|
|
if r["additions_override"] is not None
|
|
else self._derived_additions(r["year"], cca_class_id))
|
|
if r["opening_ucc_override"] is not None and not year_inputs:
|
|
opening = Decimal(str(r["opening_ucc_override"]))
|
|
year_inputs.append(YearInput(
|
|
year=r["year"],
|
|
additions=add,
|
|
dispositions=Decimal(str(r["dispositions"])),
|
|
adjustment_coeff=Decimal(str(r["adjustment_coeff"])),
|
|
pool_physically_empty=bool(r["pool_physically_empty"]),
|
|
))
|
|
|
|
results = compute_pool_timeline(year_inputs, opening_ucc=opening, cca_rate=cca_rate)
|
|
return {
|
|
"cca_class_id": cca_class_id,
|
|
"cca_rate": str(cca_rate),
|
|
"physical_count": self._physical_count(cca_class_id),
|
|
"years": [r.to_dict() for r in results],
|
|
}
|