monregistraire/app/store.py
Daniel Allaire 691537400f Références législatives : URLs vers les textes officiels (Justice Canada / ARC)
Remplace les mentions textuelles par des URLs directes dans les docstrings de
cca_pool.py, cca_registry.py et le commentaire CCA_CATEGORIES dans store.py.

  - laws-lois.justice.gc.ca : LIR art. 13, 20 ; Règl. art. 1100
  - canada.ca : Guide T2 (T4012) pour l'Annexe 8 / Schedule 8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:17:02 -04:00

580 lines
28 KiB
Python

"""
Couche de persistance SQLite — modèle à deux niveaux.
Stockage : un fichier immos.db. Chaque actif est une ligne ; la structure
JSON 3-vues (view_admin / view_cpa / view_auditor) est sérialisée en colonne
JSON. Les colonnes plates (id, type, name, parent_id) servent aux requêtes.
- PARENT : immobilisation fiscale. Base amortissable CALCULÉE = somme des
coûts des pièces rattachées (jamais éditée directement).
- PIÈCE : élément d'inventaire. Coût éditable ; il alimente la base du parent.
Édition : tout champ éditable sauf la base consolidée du parent. Chaque
modification d'un champ d'un PARENT est journalisée dans sa piste d'audit
(ancien -> nouveau + date) pour défendabilité fiscale.
"""
from __future__ import annotations
import copy
import json
import os
import sqlite3
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from threading import Lock
_db_env = os.environ.get("IMMOS_DB_PATH")
DB_PATH = Path(_db_env) if _db_env else Path(__file__).resolve().parent.parent / "immos.db"
PARENT_TYPE = "parent"
PIECE_TYPE = "piece"
# Taux de DPA par catégorie — Règlement de l'impôt sur le revenu, Annexe II
# (C.R.C., ch. 945). Art. 1100(1) Règl. : méthode du solde dégressif.
# Ces taux sont les taux ordinaires ; aucun taux d'accélération temporaire
# (p. ex. Règl. 1100(1)(a.1), 1100(1)(a.2)) n'est appliqué automatiquement.
# https://laws-lois.justice.gc.ca/fra/reglements/C.R.C.,_ch._945/section-1100.html
CCA_CATEGORIES = [
{"id": 50, "description": "Matériel informatique et logiciels de systèmes", "dpa_rate": 0.55, "default_useful_life_years": 4},
{"id": 8, "description": "Mobilier, équipement et matériel divers", "dpa_rate": 0.20, "default_useful_life_years": 10},
{"id": 10, "description": "Matériel informatique (avant mars 2007), véhicules","dpa_rate": 0.30, "default_useful_life_years": 5},
{"id": 12, "description": "Logiciels d'application, petits outils", "dpa_rate": 1.00, "default_useful_life_years": 2},
{"id": 46, "description": "Matériel d'infrastructure de réseau de données", "dpa_rate": 0.30, "default_useful_life_years": 7},
]
CCA_BY_ID = {c["id"]: c for c in CCA_CATEGORIES}
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def _compute_linear_deprec(
cost: float, isd: str, useful_life_years: float
) -> tuple[float, float, float]:
"""
Amortissement linéaire indicatif (comptable, NON fiscal).
Retourne (accum_deprec_cad, nbv_cad, remaining_life_years).
"""
if isd and len(isd) >= 10:
try:
y_, m_, d_ = int(isd[:4]), int(isd[5:7]), int(isd[8:10])
isd_dt = datetime(y_, m_, d_, tzinfo=timezone.utc)
today_dt = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
years_svc = max(0.0, (today_dt - isd_dt).days / 365.25)
except (ValueError, IndexError):
years_svc = 0.0
annual = cost / useful_life_years if useful_life_years > 0 else cost
accum = round(min(annual * years_svc, cost), 2)
nbv = round(max(cost - accum, 0.0), 2)
remaining = round(max(useful_life_years - years_svc, 0.0), 2)
else:
accum, nbv, remaining = 0.0, round(cost, 2), useful_life_years
return accum, nbv, remaining
def _compute_piece_fnacc(cost: float, acquisition_year: int, cca_rate: float) -> tuple[float, int]:
"""
FNACC théorique d'une pièce au 31 décembre du dernier exercice fiscal complété.
Méthode : amortissement dégressif, règle de la demi-année, DPA maximale chaque année.
Retourne (fnacc_cad, reference_year).
Indicatif uniquement : la FNACC fiscale légalement opposable appartient au
solde de catégorie (pool), pas à la pièce individuelle.
"""
reference_year = datetime.now(timezone.utc).year - 1
if acquisition_year > reference_year:
# Pièce acquise dans l'exercice courant — aucune DPA réclamée encore.
return (round(cost, 2), reference_year)
fnacc = cost * (1.0 - cca_rate * 0.5) # Règl. 1100(2) — demi-année
for _ in range(reference_year - acquisition_year): # Règl. 1100(1) — solde dégressif
fnacc *= (1.0 - cca_rate)
return (round(max(fnacc, 0.0), 2), reference_year)
class AssetStore:
def __init__(self, db_path: Path = DB_PATH) -> None:
self._lock = Lock()
self._db_path = db_path
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._init_schema()
# ---- Schéma ----
def _init_schema(self) -> None:
self._conn.execute("""
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
parent_id INTEGER,
data TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE SET NULL
)
""")
self._conn.commit()
# ---- Helpers ----
def _row_to_asset(self, row: sqlite3.Row) -> dict:
asset = json.loads(row["data"])
asset["id"] = row["id"]
asset["type"] = row["type"]
asset["name"] = row["name"]
asset["parent_id"] = row["parent_id"]
return asset
def _raw_get(self, asset_id: int) -> dict | None:
cur = self._conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,))
row = cur.fetchone()
return self._row_to_asset(row) if row else None
def _raw_children(self, parent_id: int) -> list[dict]:
cur = self._conn.execute("SELECT * FROM assets WHERE parent_id = ?", (parent_id,))
return [self._row_to_asset(r) for r in cur.fetchall()]
def _sum_pieces_cost(self, parent_id: int) -> Decimal:
total = Decimal("0")
for child in self._raw_children(parent_id):
total += Decimal(str(child["view_cpa"]["capitalizable_base_cad"]))
return total
def _with_computed_base(self, asset: dict) -> dict:
snap = copy.deepcopy(asset)
if snap["type"] == PARENT_TYPE:
children = self._raw_children(snap["id"])
cost_total = Decimal("0")
fnacc_total = Decimal("0")
replacement_total = Decimal("0")
nbv_linear_total = Decimal("0")
for child in children:
enriched = self._with_computed_base(child)
cost_total += Decimal(str(enriched["view_cpa"]["capitalizable_base_cad"]))
fnacc_total += Decimal(str(enriched["view_cpa"].get("fnacc_cad", 0)))
replacement_total += Decimal(str(enriched["view_admin"].get("replacement_cost_cad", 0)))
nbv_linear_total += Decimal(str(enriched["view_cpa"].get(
"nbv_linear_cad", enriched["view_cpa"]["capitalizable_base_cad"])))
snap["view_cpa"]["capitalizable_base_cad"] = float(cost_total)
snap["view_cpa"]["cost_pre_tax_cad"] = float(cost_total)
snap["view_cpa"]["net_book_value_cad"] = float(fnacc_total)
snap["view_admin"]["pieces_count"] = len(children)
snap["view_admin"]["total_replacement_cost_cad"] = float(replacement_total)
snap["view_cpa"]["nbv_linear_total_cad"] = float(nbv_linear_total)
# L'étiquette du parent est celle du porteur désigné.
bearer = next((c for c in children if c["view_admin"].get("is_tag_bearer")), None)
if bearer:
snap["view_admin"]["inventory_tag"] = bearer["view_admin"].get("inventory_tag", "")
elif snap["type"] == PIECE_TYPE:
cost = float(snap["view_cpa"].get("capitalizable_base_cad", 0))
isd = snap["view_cpa"].get("in_service_date") or ""
rate = float(snap["view_cpa"]["cca_class"].get("dpa_rate", 0.55))
override = snap["view_cpa"].get("fnacc_override_cad")
ref_year = datetime.now(timezone.utc).year - 1
if isd and isd[:4].isdigit():
# En service : amortissement dégressif depuis l'année de mise en service.
fnacc_auto, ref_year = _compute_piece_fnacc(cost, int(isd[:4]), rate)
else:
# Stock (jamais rattachée) : FNACC = coût intégral, zéro amortissement.
fnacc_auto = round(cost, 2)
snap["view_cpa"]["fnacc_auto_cad"] = fnacc_auto
snap["view_cpa"]["fnacc_reference_year"] = ref_year
snap["view_cpa"]["fnacc_is_override"] = override is not None
snap["view_cpa"]["fnacc_cad"] = float(override) if override is not None else fnacc_auto
# Amortissement linéaire indicatif (comptable, non fiscal).
useful_life_years = float(snap["view_admin"].get("useful_life_years") or
CCA_BY_ID.get(snap["view_cpa"]["cca_class"]["id"], {}).get("default_useful_life_years", 4))
snap["view_admin"]["useful_life_years"] = useful_life_years
accum_l, nbv_l, remaining_l = _compute_linear_deprec(cost, isd, useful_life_years)
snap["view_cpa"]["accum_deprec_linear_cad"] = accum_l
snap["view_cpa"]["nbv_linear_cad"] = nbv_l
snap["view_cpa"]["remaining_useful_life_years"] = remaining_l
return snap
def _persist(self, asset: dict) -> None:
"""Écrit un actif existant (UPDATE) à partir de sa structure complète."""
data = copy.deepcopy(asset)
for k in ("id", "type", "name", "parent_id"):
data.pop(k, None)
self._conn.execute(
"UPDATE assets SET type=?, name=?, parent_id=?, data=?, updated_at=? WHERE id=?",
(asset["type"], asset["name"], asset["parent_id"], json.dumps(data, ensure_ascii=False), _now_iso(), asset["id"]),
)
self._conn.commit()
# ---- Lectures ----
def _inject_bearer_tags(self, assets: list[dict]) -> None:
"""Propage l'étiquette du porteur à ses siblings et marque tag_inherited."""
bearer_tag: dict[int, str] = {}
for a in assets:
if a["type"] == PIECE_TYPE and a["parent_id"] is not None and a["view_admin"].get("is_tag_bearer"):
bearer_tag[a["parent_id"]] = a["view_admin"].get("inventory_tag", "")
for a in assets:
if a["type"] == PIECE_TYPE and a["parent_id"] is not None:
is_bearer = bool(a["view_admin"].get("is_tag_bearer"))
a["view_admin"]["tag_inherited"] = not is_bearer
if not is_bearer:
a["view_admin"]["inventory_tag"] = bearer_tag.get(a["parent_id"], "")
def all(self) -> list[dict]:
cur = self._conn.execute("SELECT * FROM assets ORDER BY id")
result = [self._with_computed_base(self._row_to_asset(r)) for r in cur.fetchall()]
self._inject_bearer_tags(result)
return result
def get(self, asset_id: int) -> dict | None:
a = self._raw_get(asset_id)
if a is None:
return None
result = self._with_computed_base(a)
if result["type"] == PIECE_TYPE and result["parent_id"] is not None:
siblings = self._raw_children(result["parent_id"])
bearer = next((s for s in siblings if s["view_admin"].get("is_tag_bearer")), None)
is_bearer = bool(result["view_admin"].get("is_tag_bearer"))
result["view_admin"]["tag_inherited"] = not is_bearer
if not is_bearer:
result["view_admin"]["inventory_tag"] = bearer["view_admin"].get("inventory_tag", "") if bearer else ""
return result
# ---- Création ----
def create_asset(self, payload: dict) -> dict:
with self._lock:
atype = payload.get("type")
if atype not in (PARENT_TYPE, PIECE_TYPE):
raise ValueError("Type invalide (parent|piece).")
name = payload.get("name") or "Immo"
cost = float(payload.get("cost_cad") or 0)
currency = payload.get("currency") or "CAD"
now = _now_iso()
data = {
"path": "",
"view_admin": {
"serial_number": payload.get("serial_number") or "",
"inventory_tag": payload.get("inventory_tag") or "",
"manufacturer": payload.get("manufacturer") or "",
"model": payload.get("model") or "",
"form_factor": payload.get("form_factor") or "",
# Propre au PARENT : assembleur de l'immobilisation.
"assembler": payload.get("assembler") or ("Chezlepro Inc." if atype == PARENT_TYPE else ""),
"canvas": {"x": 0, "y": 0, "width": 240, "height": 140, "z_index": 0},
"operational_status": payload.get("operational_status") or "Stock",
# Coût estimé pour remplacer à neuf (assurances, budget). Propre aux PIÈCES.
"replacement_cost_cad": float(payload.get("replacement_cost_cad") or 0),
# True sur exactement une pièce par immobilisation : source de l'étiquette partagée.
"is_tag_bearer": False,
},
"view_cpa": {
"original_cost": {"amount": cost, "currency": currency},
"exchange_rate_applied": float(payload.get("exchange_rate") or 1.0),
"exchange_rate_date": payload.get("acquisition_date") or now[:10],
"cost_pre_tax_cad": cost,
"non_recoverable_taxes_and_duties": float(payload.get("non_recoverable_taxes_and_duties") or 0),
"capitalizable_base_cad": cost,
# FNACC saisie manuellement (override). Null = utiliser le calcul auto.
"fnacc_override_cad": payload.get("fnacc_override_cad"),
"cca_class": {
"id": int(payload.get("cca_class_id") or 50),
"description": CCA_BY_ID.get(int(payload.get("cca_class_id") or 50), {}).get("description", "Matériel informatique"),
"dpa_rate": CCA_BY_ID.get(int(payload.get("cca_class_id") or 50), {}).get("dpa_rate", 0.55),
},
"acquisition_date": payload.get("acquisition_date") or now[:10],
# Parent : date du jour par défaut. Pièce libre : vide — ne s'amortit
# pas tant qu'elle n'est pas rattachée (in_service_date posée par attach).
"in_service_date": payload.get("in_service_date") or (now[:10] if atype == PARENT_TYPE else ""),
"half_year_rule_applies": bool(payload.get("half_year_rule_applies", True)),
},
"view_auditor": {
"supplier": payload.get("supplier") or "",
"invoice_pdf_file_id": payload.get("invoice_pdf_file_id") or "",
"invoice_number": payload.get("invoice_number") or "",
"audit_trail": [{
"action": "CREATED",
"description": f"{'Immobilisation' if atype==PARENT_TYPE else 'Pièce'} créée",
"timestamp": now,
"previous_value": None,
"new_value": {"type": atype, "parent_id": None},
}],
},
}
cur = self._conn.execute(
"INSERT INTO assets (type, name, parent_id, data, created_at, updated_at) VALUES (?,?,?,?,?,?)",
(atype, name, None, json.dumps(data, ensure_ascii=False), now, now),
)
self._conn.commit()
new_id = cur.lastrowid
self._set_path(new_id)
return self.get(new_id)
def _set_path(self, asset_id: int) -> None:
a = self._raw_get(asset_id)
if not a:
return
if a["parent_id"]:
a["path"] = f"parent_{a['parent_id']}.piece_{asset_id}"
else:
a["path"] = (f"parent_{asset_id}" if a["type"] == PARENT_TYPE else f"libre_{asset_id}")
self._persist(a)
# ---- Édition ----
# Champs éditables par chemin "view.cle". La base consolidée du parent est exclue.
def update_asset(self, asset_id: int, changes: dict) -> dict:
with self._lock:
asset = self._raw_get(asset_id)
if asset is None:
raise KeyError(f"Actif {asset_id} introuvable.")
is_parent = asset["type"] == PARENT_TYPE
audit_changes = []
for key, new_value in changes.items():
# Interdit : éditer la base consolidée d'un parent (calculée).
if is_parent and key in ("view_cpa.capitalizable_base_cad", "view_cpa.cost_pre_tax_cad"):
raise ValueError("La base consolidée d'un parent est calculée et non éditable.")
old_value = self._get_path(asset, key)
if old_value == new_value:
continue
self._set_path_value(asset, key, new_value)
audit_changes.append((key, old_value, new_value))
# Nom de premier niveau si modifié via view ? On gère 'name' à part.
if "name" in changes and changes["name"] != asset["name"]:
audit_changes.append(("name", asset["name"], changes["name"]))
asset["name"] = changes["name"]
# Si la catégorie DPA change, le taux et la description suivent (catalogue).
if "view_cpa.cca_class.id" in changes:
cid = int(changes["view_cpa.cca_class.id"])
cat = CCA_BY_ID.get(cid)
if cat:
asset["view_cpa"]["cca_class"]["dpa_rate"] = cat["dpa_rate"]
asset["view_cpa"]["cca_class"]["description"] = cat["description"]
# Journalisation : toujours pour un parent (données fiscales).
if is_parent and audit_changes:
for (field, old, new) in audit_changes:
asset["view_auditor"]["audit_trail"].append({
"action": "EDITED",
"description": f"Champ « {field} » modifié",
"timestamp": _now_iso(),
"previous_value": {field: old},
"new_value": {field: new},
})
self._persist(asset)
return self.get(asset_id)
def _get_path(self, obj: dict, dotted: str):
cur = obj
for part in dotted.split("."):
if not isinstance(cur, dict) or part not in cur:
return None
cur = cur[part]
return cur
def _set_path_value(self, obj: dict, dotted: str, value) -> None:
parts = dotted.split(".")
cur = obj
for part in parts[:-1]:
cur = cur.setdefault(part, {})
cur[parts[-1]] = value
# ---- Attache / Détache ----
def attach(self, piece_id: int, parent_id: int) -> dict:
with self._lock:
piece = self._raw_get(piece_id)
parent = self._raw_get(parent_id)
if piece is None:
raise KeyError(f"Pièce {piece_id} introuvable.")
if parent is None:
raise KeyError(f"Parent {parent_id} introuvable.")
if parent["type"] != PARENT_TYPE:
raise ValueError("La cible n'est pas une immobilisation parent.")
if piece["type"] != PIECE_TYPE:
raise ValueError("Seule une pièce peut être rattachée.")
old_parent_id = piece["parent_id"]
piece["parent_id"] = parent_id
piece["path"] = f"parent_{parent_id}.piece_{piece_id}"
piece["view_admin"]["operational_status"] = "Actif"
now = _now_iso()
# Pose la date de mise en service au premier rattachement seulement.
# Re-rattachement (pièce déjà en service) : date conservée, DPA continue.
if not piece["view_cpa"].get("in_service_date"):
today = now[:10]
piece["view_cpa"]["in_service_date"] = today
piece["view_auditor"]["audit_trail"].append({
"action": "IN_SERVICE",
"description": f"Mise en service posée automatiquement au rattachement au parent {parent_id} ({today})",
"timestamp": now,
"previous_value": {"in_service_date": None},
"new_value": {"in_service_date": today},
})
piece["view_auditor"]["audit_trail"].append({
"action": "ATTACHED",
"description": f"Rattachée au parent {parent_id}",
"timestamp": now,
"previous_value": {"parent_id": old_parent_id},
"new_value": {"parent_id": parent_id},
})
self._persist(piece)
return self.get(parent_id)
def detach(self, piece_id: int, disposition: str) -> dict:
with self._lock:
piece = self._raw_get(piece_id)
if piece is None:
raise KeyError(f"Pièce {piece_id} introuvable.")
old_parent_id = piece["parent_id"]
piece["parent_id"] = None
piece["path"] = f"libre_{piece_id}"
status = "Rebut" if disposition == "scrap" else "Stock"
piece["view_admin"]["operational_status"] = status
piece["view_admin"]["inventory_tag"] = ""
piece["view_admin"]["is_tag_bearer"] = False
piece["view_auditor"]["audit_trail"].append({
"action": "DETACHED",
"description": f"Détachée du parent {old_parent_id} — disposition {disposition}",
"timestamp": _now_iso(),
"previous_value": {"parent_id": old_parent_id},
"new_value": {"parent_id": None, "operational_status": status},
})
self._persist(piece)
return self.get(piece_id)
# ---- Porteur d'étiquette ----
def set_tag_bearer(self, piece_id: int) -> list[dict]:
"""Désigne une pièce comme porteur d'étiquette pour son immobilisation parente.
- Tous les siblings perdent is_tag_bearer.
- L'étiquette affichée sur tous les siblings et sur le parent est déduite
du porteur à la lecture (aucun stockage sur les non-porteurs).
- Migration rétroactive : si la pièce n'a pas d'étiquette et le parent en a
une, celle-ci est copiée sur la pièce porteur.
"""
with self._lock:
piece = self._raw_get(piece_id)
if piece is None:
raise KeyError(f"Pièce {piece_id} introuvable.")
if piece["parent_id"] is None:
raise ValueError("La pièce doit être rattachée à une immobilisation pour être porteur d'étiquette.")
parent_id = piece["parent_id"]
parent = self._raw_get(parent_id)
if parent is None:
raise KeyError(f"Parent {parent_id} introuvable.")
now = _now_iso()
# Migration rétroactive : hérite l'étiquette du parent si la pièce n'en a pas.
if not piece["view_admin"].get("inventory_tag") and parent["view_admin"].get("inventory_tag"):
piece["view_admin"]["inventory_tag"] = parent["view_admin"]["inventory_tag"]
# Retirer le statut porteur des autres pièces.
for sibling in self._raw_children(parent_id):
if sibling["id"] == piece_id:
continue
if sibling["view_admin"].get("is_tag_bearer"):
sibling["view_admin"]["is_tag_bearer"] = False
sibling["view_auditor"]["audit_trail"].append({
"action": "TAG_BEARER_REMOVED",
"description": f"Statut porteur retiré (nouveau porteur : pièce {piece_id})",
"timestamp": now,
"previous_value": {"is_tag_bearer": True},
"new_value": {"is_tag_bearer": False},
})
self._persist(sibling)
# Désigner cette pièce comme porteur.
tag = piece["view_admin"].get("inventory_tag", "")
piece["view_admin"]["is_tag_bearer"] = True
piece["view_auditor"]["audit_trail"].append({
"action": "TAG_BEARER_SET",
"description": f"Désignée porteur d'étiquette pour l'immo. {parent_id} — étiquette : {tag!r}",
"timestamp": now,
"previous_value": {"is_tag_bearer": False},
"new_value": {"is_tag_bearer": True},
})
self._persist(piece)
# Journaliser sur le parent.
parent["view_auditor"]["audit_trail"].append({
"action": "TAG_BEARER_CHANGED",
"description": f"Porteur d'étiquette → pièce {piece_id} ({piece['name']}) — étiquette : {tag!r}",
"timestamp": now,
"previous_value": None,
"new_value": {"tag_bearer_piece_id": piece_id, "inventory_tag": tag},
})
self._persist(parent)
return self.all()
# ---- Suppression ----
def delete_asset(self, asset_id: int) -> None:
with self._lock:
# Détache d'abord les pièces enfants (deviennent libres).
for child in self._raw_children(asset_id):
child["parent_id"] = None
child["path"] = f"libre_{child['id']}"
child["view_admin"]["operational_status"] = "Stock"
self._persist(child)
self._conn.execute("DELETE FROM assets WHERE id = ?", (asset_id,))
self._conn.commit()
# ---- Export fiscal ----
def fiscal_export(self) -> dict:
"""Regroupe les PARENTS par catégorie DPA avec base consolidée."""
parents = [a for a in self.all() if a["type"] == PARENT_TYPE]
by_class: dict[int, dict] = {}
for p in parents:
cca = p["view_cpa"]["cca_class"]
cid = cca["id"]
entry = by_class.setdefault(cid, {
"cca_class_id": cid,
"description": cca["description"],
"dpa_rate": cca["dpa_rate"],
"items": [],
"total_base_cad": 0.0,
})
base = float(p["view_cpa"]["capitalizable_base_cad"])
entry["items"].append({
"id": p["id"],
"name": p["name"],
"inventory_tag": p["view_admin"]["inventory_tag"],
"base_cad": base,
"in_service_date": p["view_cpa"]["in_service_date"],
"half_year_rule": p["view_cpa"]["half_year_rule_applies"],
"pieces_count": p["view_admin"].get("pieces_count", 0),
})
entry["total_base_cad"] = round(entry["total_base_cad"] + base, 2)
return {"classes": list(by_class.values()), "generated_at": _now_iso()}
def reset(self) -> None:
with self._lock:
self._conn.execute("DELETE FROM assets")
self._conn.execute("DELETE FROM cca_pool_years")
self._conn.commit()
def backup(self, dest_path: Path) -> None:
dest_path.parent.mkdir(parents=True, exist_ok=True)
dest_conn = sqlite3.connect(str(dest_path))
with self._lock:
self._conn.backup(dest_conn)
dest_conn.close()
def restore_from(self, src_path: Path) -> None:
src_conn = sqlite3.connect(str(src_path))
with self._lock:
src_conn.backup(self._conn)
src_conn.close()
store = AssetStore()
# Registre fiscal (pool de catégorie) branché sur le même store/connexion.
from .cca_registry import CcaRegistry
registry = CcaRegistry(store._conn, store, store._lock)