Compare commits

...

10 Commits

Author SHA1 Message Date
b438cbf4cd ci: workflow Gitea de build et publication de l'image Docker
Some checks failed
Build and Publish Docker Image / Build App Image (push) Failing after 37m40s
Build and Publish Docker Image / Build Summary (push) Has been cancelled
Build de l'image unique depuis backend.Dockerfile et push vers le
registre (secrets REGISTRY_*). Déclenché sur main, tags v* et manuel.
Tags d'image : latest/main sur la branche par défaut, semver
(X.Y.Z / X.Y / X) sur les tags. Pas de tag par SHA (économie d'espace
registre).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:15:29 +02:00
0d39ea810b feat: déploiement Docker en conteneur unique (API + UI)
Le backend FastAPI sert déjà le SPA buildé : on abandonne le duo
nginx + backend au profit d'une seule image multi-stage (build Vue
puis service par le backend). docker-compose passe à un service
unique, avec PLESNA_DATA_DIR déclarant le stockage persistant.

- backend.Dockerfile : stage node build -> copie frontend/dist,
  CMD en `uv run --no-sync` (plus de resync des deps dev au démarrage)
- docker-compose.yml : service unique `app`, volume ./data,
  PLESNA_DATA_DIR=/app/data
- supprime frontend/Dockerfile, frontend/nginx.conf, frontend/.dockerignore
- .dockerignore : ignore **/node_modules, data, data_bck
- README : section Production mise à jour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:56 +02:00
c79ecc45ff feat: extraction locataires et opérations par cellules de tableau
Remplace les parseurs texte+regex (fragiles sur l'alignement en colonnes des
PDF Oralia mal construits) par une extraction géométrique : reconstruction des
lignes visuelles par regroupement vertical tolérant des mots, puis affectation
de chaque valeur à sa colonne via les filets du tableau (pdfplumber find_tables).

Corrections apportées :
- locataires : montant/libellé « divers » dans la bonne colonne (plus le total
  cumulé du lot), nom de locataire correct (le logo/en-tête hors filets est
  ignoré), lignes multi-période et pages recollées.
- opérations : fournisseur séparé de la description (TOTALENERGIES ≠ DIDIER
  NETTOYAGE, PPR ≠ BOUVARD), colonne Déductible remplie, Débit/Crédit distingués,
  fournisseur des honoraires reporté sur le bloc, fragments de description
  recollés (LATAPY, AUDOUIN).
- code lot : gère « S10 - », « S 17 - » (espace) et « S01 SOLDE » (sans tiret).

Branchés dans extractor.py avec repli sur les anciens parseurs si un tableau n'a
pas de filets détectables. Validés par réconciliation comptable : locataires
124/124 lots, opérations 7/7 PDF et 25/25 catégories, au centime.

Ajoute le bouton « Relancer l'extraction » dans l'écran d'édition : re-extrait
depuis le PDF stocké et met en évidence les différences avec la version
précédente (panneau récapitulatif + anneaux « modifié » sur les cartes). Le diff
des opérations s'aligne par contenu (robuste aux changements d'ordre/nombre).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:29:04 +02:00
2d8b2ff42f feat: apercu PDF via PDF.js (rendu portable, build legacy)
Remplace l'<iframe> (lecteur PDF natif du moteur) par un rendu PDF.js
sur <canvas>, une page par canvas empilee et scrollable. Rendu
identique partout (Qt WebEngine, WebView2, navigateur), worker bundle
localement par Vite -> fonctionne hors-ligne.

Utilise le build *legacy* de pdfjs-dist : il embarque les polyfills
requis par les Chromium embarques (Qt WebEngine, WebView2), plus
anciens que Chrome. Le build par defaut appelle
`Map.prototype.getOrInsertComputed` (proposition JS recente) sans
polyfill, ce qui cassait silencieusement le rendu dans la fenetre
bureau. Le catch logue desormais l'erreur au lieu de l'avaler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:22:20 +02:00
a90d1c4f64 feat: mode application de bureau + packaging Windows autonome
Permet de distribuer l'application en executable Windows double-clic,
sans Python/Node/terminal pour l'utilisateur final.

- `desktop.py` : demarre le serveur sur un port libre dans un thread et
  ouvre une fenetre native (pywebview). `PLESNA_DEBUG=1` active les
  DevTools. Commande `plesna-gerance desktop` (import paresseux).
- `paths.py` : resolution centralisee des chemins selon le contexte
  (dev, executable PyInstaller, surcharge par variables d'env). Les
  donnees vont dans un dossier utilisateur inscriptible (%APPDATA%),
  les ressources dans le bundle. connection.py/storage.py utilisent
  `get_data_dir()`, app.py `resource_path()`.
- app.py : type MIME `.mjs` force (modules ES / worker PDF.js sous
  Windows).
- Groupes de deps optionnels desktop / desktop-linux / build.
- packaging/ : spec PyInstaller, build_windows.ps1, installeur Inno
  Setup. Workflow GitHub build-windows.yml (runner Windows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:21:27 +02:00
7cebc0bb3d feat: migre l'extraction de pdftotext (poppler) vers pdfplumber
Remplace l'appel externe `pdftotext -layout` (poppler-utils) par
pdfplumber, une bibliotheque Python pure : plus aucune dependance
systeme, ce qui permettra d'empaqueter l'application en executable
autonome.

- `read_pdf()` retourne un `PdfContent` (texte layout + mots de la
  premiere page avec coordonnees) ; `extract_text_from_pdf()` est
  conserve comme wrapper de compatibilite.
- Metadonnees : la reference est desormais extraite via les
  coordonnees geometriques des mots (fiable sur l'en-tete
  multi-colonnes), regex du destinataire durcie (forme pointee
  `S.C.I.`).
- Operations : reduction des espaces multiples dans les descriptions.
- Dockerfile : suppression de poppler-utils.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:14:31 +02:00
d42bfecab4 chore: ajoute ruff (lint) et corrige les violations
- Config ruff dans pyproject.toml : règles E/W/F/I/UP/B, whitelist des appels
  d'injection FastAPI (Depends/File/Form/Query) pour B008, B904 ignoré
  (traduction volontaire des exceptions en réponses HTTP)
- Auto-fixes : tri des imports, suppression d'imports inutilisés, annotations
  PEP 604, f-strings sans placeholder, modes open redondants
- Suppression de variables inutilisées (config.reset_setting, parser locataires)

ruff check . : All checks passed ; 61 tests OK

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:20:36 +02:00
4606d6785b refactor: factorise la création d'engine et normalise solde_montant
- connection: création de l'engine centralisée dans _build_engine (source
  unique de config) ; init_db réutilise reset_connection + get_engine au lieu
  de dupliquer create_engine
- service: _normalize_amount garantit qu'un montant non numérique issu de
  l'extraction (string, dict…) n'entre jamais en base dans une colonne Float ;
  appliqué à solde_montant
- tests: couverture de _normalize_amount et du solde sous forme de string

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:15:09 +02:00
635a094591 feat: durcit l'exécution SQL IA, borne les uploads et ajoute des tests
- sql_executor: remplace le filtre regex fragile par une autorisation SQLite
  (set_authorizer) en complément de mode=ro ; rejette les instructions multiples
- uploads: lecture bornée des PDF (helper read_upload_limited, limite 20 Mo,
  HTTP 413) branchée sur /extract et /save-with-pdf
- tests: suite pytest (54 tests) couvrant amounts, dates, storage, sql_executor,
  uploads et DatabaseService.save_document ; pytest ajouté en dépendance dev

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:11:07 +02:00
c15b65f0d9 fix: corrige path traversal, fuites d'erreurs et dépréciations
- Empêche le path traversal du catch-all SPA (vérif is_relative_to dist)
- Migre on_event("startup") vers le handler lifespan de FastAPI
- Masque les exceptions internes des erreurs 500 (log au lieu de str(e))
- Remplace datetime.utcnow déprécié par un helper UTC timezone-aware
- Simplifie except (ValueError, Exception) redondant

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 10:01:48 +02:00
66 changed files with 3939 additions and 322 deletions

View File

@@ -3,9 +3,14 @@
venv
__pycache__
*.pyc
# node_modules réinstallés dans le stage de build (à tous les niveaux)
node_modules
**/node_modules
# frontend/dist est rebuildé dans l'image, jamais copié depuis l'hôte
frontend/dist
# données locales : ne jamais les embarquer dans l'image
data
data_bck
*.egg-info
.mypy_cache
.ruff_cache

View File

@@ -0,0 +1,82 @@
name: Build and Publish Docker Image
on:
push:
branches:
- main
tags:
- "v*"
workflow_dispatch:
env:
REGISTRY: ${{ secrets.REGISTRY_URL }}
NAMESPACE: ${{ secrets.REGISTRY_NAMESPACE }}
jobs:
build:
name: Build App Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=Plesna Gerance
org.opencontainers.image.description=Extracteur de comptes rendus de gerance Oralia/ICS (API + interface web)
org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
org.opencontainers.image.version=${{ gitea.ref_name }}
- name: Build and push image
id: build
uses: docker/build-push-action@v5
with:
# Conteneur unique : l'image builde le frontend Vue puis le fait
# servir par le backend FastAPI (cf. backend.Dockerfile).
context: .
file: ./backend.Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
- name: Image digest
run: |
echo "Image pushed with digest: ${{ steps.build.outputs.digest }}"
echo "Tags: ${{ steps.meta.outputs.tags }}"
summary:
name: Build Summary
runs-on: ubuntu-latest
needs: [build]
if: always()
steps:
- name: Build summary
run: |
echo "## 🐳 Docker Image Built Successfully"
echo ""
echo "- Registry: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/plesna-gerance"
echo "- Tags: latest, ${{ gitea.ref_name }}"
echo ""
echo "### 🚀 Deployment"
echo "docker compose up -d"

55
.github/workflows/build-windows.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Build Windows
# Construit l'exécutable autonome et l'installeur sur un runner Windows.
# Déclenchable manuellement (onglet Actions) ou en poussant un tag v*.
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies (desktop + build)
run: uv sync --group desktop --group build
- name: Package with PyInstaller
run: uv run pyinstaller packaging/plesna_gerance.spec --noconfirm --clean
- name: Install Inno Setup
run: choco install innosetup --no-progress -y
- name: Build installer
run: '& "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" packaging\installer.iss'
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: PlesnaGerance-windows
path: |
dist/PlesnaGerance.exe
dist/PlesnaGerance-Setup.exe
if-no-files-found: error

4
.gitignore vendored
View File

@@ -25,6 +25,10 @@ frontend/dist/
# Sample data and outputs
sample/
output.json
data/
# Références golden : générées depuis des PDF réels, données non versionnées
tests/golden/
# OS
.DS_Store

View File

@@ -7,7 +7,9 @@ Extracteur de comptes rendus de gerance Oralia/ICS.
- Python >= 3.10
- [uv](https://docs.astral.sh/uv/)
- Node.js >= 22
- poppler-utils (`apt install poppler-utils` ou `pacman -S poppler`)
> L'extraction de PDF utilise `pdfplumber` (Python pur) : aucun binaire
> systeme requis (plus besoin de `poppler-utils`/`pdftotext`).
## Installation
@@ -35,8 +37,76 @@ Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backe
## Production (Docker)
Un **conteneur unique** : l'image builde le frontend Vue puis le fait servir
par le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
```bash
make docker
make docker # podman-compose up --build
# ou : docker compose up --build
```
Accessible sur **http://localhost:8080**.
Les donnees (base SQLite + documents) sont declarees via `PLESNA_DATA_DIR`
(voir `docker-compose.yml`) et persistees dans le volume `./data`, donc
conservees entre les redemarrages du conteneur.
## Application de bureau (Windows, sans ligne de commande)
L'application peut etre empaquetee en **executable Windows autonome** : un seul
fichier que l'utilisateur final installe et lance via une icone, sans Python,
sans Node, sans terminal. L'extraction etant en Python pur, aucun binaire
externe n'est requis.
### Tester le mode bureau (depuis les sources)
Sous **Windows** (pywebview utilise WebView2, deja present) :
```bash
uv sync --group desktop
cd frontend && npm run build && cd ..
uv run plesna-gerance desktop # ouvre une fenetre native
```
Sous **Linux**, pywebview a besoin d'un moteur de rendu. Le groupe
`desktop-linux` fournit un backend Qt entierement pip-installable :
```bash
uv sync --group desktop-linux
cd frontend && npm run build && cd ..
uv run plesna-gerance desktop # necessite un environnement graphique ($DISPLAY)
```
> Ce backend Qt ne sert qu'a **tester** le mode fenetre sous Linux : il n'est
> pas embarque dans le build Windows.
### Produire l'executable + l'installeur
Deux options (le build doit se faire **sur Windows**, PyInstaller ne croise pas
les plateformes) :
1. **Sur une machine Windows** — installer uv, Node.js et (optionnel) Inno
Setup 6, puis :
```powershell
powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1
```
Produit `dist\PlesnaGerance.exe` et, si Inno Setup est present,
`dist\PlesnaGerance-Setup.exe`.
2. **Sans machine Windows** — via GitHub Actions : le workflow
`.github/workflows/build-windows.yml` compile sur un runner Windows.
Le declencher (onglet *Actions* ou en poussant un tag `v*`) puis telecharger
l'artefact `PlesnaGerance-windows`.
### Cote utilisateur final
Lancer `PlesnaGerance-Setup.exe`, suivre l'assistant (installation par
utilisateur, sans droits administrateur), puis cliquer sur l'icone **Plesna
Gerance**. Les donnees (base et documents) sont stockees dans
`%APPDATA%\PlesnaGerance` et conservees entre les mises a jour.
> **Assistant IA (optionnel)** : la page IA necessite [Ollama](https://ollama.com)
> installe separement. Sans Ollama, l'application fonctionne normalement et la
> page IA indique simplement que le service est indisponible.

View File

@@ -1,9 +1,21 @@
# syntax=docker/dockerfile:1
# --- Étape 1 : build du frontend Vue -----------------------------------------
# Le backend FastAPI sert lui-même le SPA (frontend/dist) : un seul conteneur
# suffit, pas de nginx séparé. On builde donc le frontend ici puis on copie le
# résultat dans l'image Python finale.
FROM node:22-alpine AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# --- Étape 2 : image applicative Python --------------------------------------
FROM python:3.12-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends poppler-utils && \
rm -rf /var/lib/apt/lists/*
# pdfplumber est en Python pur : aucune dépendance système (plus de poppler).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
@@ -14,6 +26,11 @@ RUN uv sync --frozen --no-dev --no-install-project
COPY src/ src/
RUN uv sync --frozen --no-dev
# Frontend buildé : servi par le backend via le fallback SPA (api/app.py).
COPY --from=frontend /frontend/dist/ frontend/dist/
EXPOSE 8000
CMD ["uv", "run", "plesna-gerance", "serve", "--host", "0.0.0.0"]
# --no-sync : ne pas re-résoudre/installer au démarrage (l'env est déjà figé
# par les `uv sync --frozen` ci-dessus). Sinon `uv run` réinstalle les deps dev.
CMD ["uv", "run", "--no-sync", "plesna-gerance", "serve", "--host", "0.0.0.0"]

View File

@@ -1,21 +1,16 @@
services:
backend:
app:
build:
context: .
dockerfile: backend.Dockerfile
ports:
# hôte:conteneur — l'app (API + interface web) écoute sur 8000
- "8080:8000"
volumes:
# Persiste base SQLite + documents hors du conteneur.
- ./data:/app/data
environment:
- PLESNA_DB_PATH=/app/data/database.sqlite
- PLESNA_STORAGE_PATH=/app/data/documents
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
depends_on:
- backend
# Déclare l'emplacement des données ; get_data_dir() en dérive
# database.sqlite et documents/ (cf. src/plesna_gerance/paths.py).
- PLESNA_DATA_DIR=/app/data
restart: unless-stopped

View File

@@ -1,2 +0,0 @@
node_modules
dist

View File

@@ -1,12 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -1,19 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy API requests to backend
location /api {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 50M;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"chart.js": "^4.4.1",
"pdfjs-dist": "^6.0.227",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.6.4"
@@ -515,6 +516,271 @@
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz",
"integrity": "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.0",
"@napi-rs/canvas-darwin-arm64": "1.0.0",
"@napi-rs/canvas-darwin-x64": "1.0.0",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.0",
"@napi-rs/canvas-linux-arm64-musl": "1.0.0",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-musl": "1.0.0",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.0",
"@napi-rs/canvas-win32-x64-msvc": "1.0.0"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz",
"integrity": "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz",
"integrity": "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz",
"integrity": "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1680,6 +1946,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.0.227",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz",
"integrity": "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"chart.js": "^4.4.1",
"pdfjs-dist": "^6.0.227",
"vue": "^3.4.21",
"vue-chartjs": "^5.3.0",
"vue-router": "^4.6.4"

View File

@@ -116,6 +116,7 @@
:locataire="loc"
:index="idx"
:highlighted="highlightedLocataireIndex === idx"
:changed="changedLocataireSet.has(idx)"
@update:locataire="updateLocataire(idx, $event)"
@remove="removeLocataire(idx)"
/>
@@ -147,6 +148,7 @@
:categorie="group.categorie"
:operations="group.operations"
:highlightIndex="group.categorie === highlightOperationCategorie ? highlightOperationIndex : -1"
:changedIndices="changedOpByCategorie[group.categorie] || []"
@update:operations="updateOperationsGroup(group.categorie, $event)"
/>
@@ -187,6 +189,10 @@ const props = defineProps({
highlight: {
type: Object,
default: null
},
diff: {
type: Object,
default: null
}
})
@@ -213,6 +219,30 @@ const operationsGroupedByCategory = computed(() => {
return groups
})
// Indices de locataires modifiés par la re-extraction (anneau « modifié »).
const changedLocataireSet = computed(
() => new Set(props.diff?.changedLocataireIndices || [])
)
// Indices d'opérations modifiées, ramenés au repère (catégorie, index dans le groupe).
const changedOpByCategorie = computed(() => {
const result = {}
const operations = props.data?.data?.recapitulatif_operations
const changed = new Set(props.diff?.changedOperationIndices || [])
if (!operations || !changed.size) return result
const counters = {}
operations.forEach((op, absIdx) => {
const cat = op.categorie || 'AUTRES'
if (!(cat in counters)) counters[cat] = 0
if (changed.has(absIdx)) {
if (!result[cat]) result[cat] = []
result[cat].push(counters[cat])
}
counters[cat]++
})
return result
})
const copyLabel = ref('Copier')
const expandedSections = reactive({
metadata: true,
@@ -269,6 +299,17 @@ watch(
{ immediate: true }
)
// Déplier automatiquement les sections qui contiennent des différences.
watch(
() => props.diff,
(diff) => {
if (!diff) return
if (diff.summary?.metadata) expandedSections.metadata = true
if (diff.summary?.locataires) expandedSections.locataires = true
if (diff.summary?.operations) expandedSections.operations = true
}
)
function toggleSection(section) {
expandedSections[section] = !expandedSections[section]
}

View File

@@ -1,5 +1,5 @@
<template>
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="isHighlighted ? 'border-blue-400 ring-2 ring-blue-400 bg-blue-50' : 'border-gray-200'">
<div ref="rootEl" class="border rounded-lg overflow-hidden transition-all duration-300" :class="changed ? 'border-amber-400 ring-2 ring-amber-400 bg-amber-50' : (isHighlighted ? 'border-blue-400 ring-2 ring-blue-400 bg-blue-50' : 'border-gray-200')">
<!-- Header -->
<div class="w-full flex items-center justify-between px-3 py-2 bg-white hover:bg-gray-50 transition-colors">
<button
@@ -43,6 +43,12 @@
/>
</button>
<div class="flex items-center gap-2">
<span
v-if="changed"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-300"
>
Modifié
</span>
<div class="text-right">
<div class="text-sm font-semibold" :class="totalClass">
{{ formatCurrency(locataire.totaux?.total) }}
@@ -157,11 +163,29 @@
displayClass="text-xs"
/>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Total -->
<!-- Libelle divers -->
<EditableField
v-if="ligne.type === 'divers'"
:modelValue="ligne.divers?.libelle"
@update:modelValue="updateLigneField(idx, 'divers.libelle', $event)"
type="text"
placeholder="libellé"
displayClass="text-xs text-gray-600 italic truncate"
/>
</div>
<div class="flex items-center gap-2">
<!-- Montant : divers -> montant divers ; sinon total (ou loyers) -->
<EditableField
v-if="ligne.type === 'divers'"
:modelValue="ligne.divers?.montant"
@update:modelValue="updateLigneField(idx, 'divers.montant', $event)"
type="currency"
displayClass="font-medium text-gray-800"
/>
<EditableField
v-else
:modelValue="ligne.total || ligne.loyers"
@update:modelValue="updateLigneField(idx, 'total', $event)"
type="currency"
@@ -215,6 +239,10 @@ const props = defineProps({
highlighted: {
type: Boolean,
default: false
},
changed: {
type: Boolean,
default: false
}
})

View File

@@ -21,6 +21,12 @@
</span>
</button>
<div class="flex items-center gap-2">
<span
v-if="changedIndices.length"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 border border-amber-300"
>
{{ changedIndices.length }} modifiée{{ changedIndices.length > 1 ? 's' : '' }}
</span>
<div class="text-sm font-semibold text-gray-700">
{{ formatCurrency(totalDebit) }}
</div>
@@ -45,7 +51,7 @@
:key="idx"
:ref="el => { if (el) opRefs[idx] = el }"
class="px-3 py-2 bg-white text-xs transition-all duration-300"
:class="highlightedIdx === idx ? 'ring-2 ring-blue-400 bg-blue-50' : ''"
:class="changedIndices.includes(idx) ? 'ring-2 ring-amber-400 bg-amber-50' : (highlightedIdx === idx ? 'ring-2 ring-blue-400 bg-blue-50' : '')"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0 space-y-1">
@@ -201,6 +207,10 @@ const props = defineProps({
highlightIndex: {
type: Number,
default: -1
},
changedIndices: {
type: Array,
default: () => []
}
})

View File

@@ -5,63 +5,142 @@
<span class="text-sm font-medium truncate">{{ fileName }}</span>
</div>
<!-- PDF via native browser viewer -->
<iframe
v-if="pdfUrl"
:src="pdfUrl"
type="application/pdf"
class="flex-1 w-full border-0"
<!-- États -->
<div
v-if="loading"
class="flex-1 flex items-center justify-center text-gray-400 text-sm"
>
Chargement du PDF
</div>
<div
v-else-if="error"
class="flex-1 flex items-center justify-center px-4 text-center text-red-400 text-sm"
>
{{ error }}
</div>
<!-- Rendu PDF.js : une <canvas> par page, empilées et scrollables.
Ne dépend pas du lecteur PDF natif du moteur (Qt WebEngine,
WebView2, navigateur) -> rendu identique partout. -->
<div
v-else-if="hasSource"
ref="container"
class="flex-1 overflow-auto p-2 bg-gray-800"
/>
<!-- Empty state -->
<div v-else class="flex-1 flex items-center justify-center text-gray-400 text-sm">
<div
v-else
class="flex-1 flex items-center justify-center text-gray-400 text-sm"
>
Aucun PDF sélectionné
</div>
</div>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue'
import { ref, watch, onUnmounted, nextTick } from 'vue'
// Build « legacy » (et non le build par défaut) : il embarque les polyfills
// nécessaires aux moteurs Chromium embarqués (Qt WebEngine, WebView2), plus
// anciens que la dernière version de Chrome. Le build moderne utilise
// `Map.prototype.getOrInsertComputed` (proposition JS récente) sans polyfill,
// ce qui casse le rendu dans la fenêtre bureau. Voir docs pdf.js « legacy ».
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
import PdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url'
// Worker bundlé localement par Vite (pas de CDN) -> fonctionne hors-ligne.
pdfjsLib.GlobalWorkerOptions.workerSrc = PdfWorker
const props = defineProps({
file: {
type: File,
default: null
},
url: {
type: String,
default: null
},
fileName: {
type: String,
default: 'document.pdf'
}
file: { type: File, default: null },
url: { type: String, default: null },
fileName: { type: String, default: 'document.pdf' }
})
const pdfUrl = ref(null)
let objectUrl = null
const container = ref(null)
const loading = ref(false)
const error = ref(null)
const hasSource = ref(false)
function cleanup() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
let loadingTask = null
// Jeton de génération : invalide les rendus concurrents (changement de source).
let renderToken = 0
async function resolveSource() {
if (props.file) return { data: await props.file.arrayBuffer() }
if (props.url) return { url: props.url }
return null
}
async function destroyTask() {
if (loadingTask) {
try {
await loadingTask.destroy()
} catch {
/* ignore */
}
loadingTask = null
}
}
watch([() => props.file, () => props.url], ([newFile, newUrl]) => {
cleanup()
async function render() {
const token = ++renderToken
error.value = null
await destroyTask()
if (newUrl) {
pdfUrl.value = newUrl
} else if (newFile) {
objectUrl = URL.createObjectURL(newFile)
pdfUrl.value = objectUrl
} else {
pdfUrl.value = null
const source = await resolveSource()
hasSource.value = source !== null
if (!source) {
loading.value = false
return
}
}, { immediate: true })
loading.value = true
try {
loadingTask = pdfjsLib.getDocument(source)
const doc = await loadingTask.promise
if (token !== renderToken) return
// Le conteneur n'est monté qu'une fois loading=false.
loading.value = false
await nextTick()
const el = container.value
if (!el || token !== renderToken) return
el.innerHTML = ''
const targetWidth = Math.max(el.clientWidth - 16, 0)
const dpr = window.devicePixelRatio || 1
for (let n = 1; n <= doc.numPages; n++) {
if (token !== renderToken) return
const page = await doc.getPage(n)
const base = page.getViewport({ scale: 1 })
const scale = targetWidth > 0 ? targetWidth / base.width : 1
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.width = Math.floor(viewport.width * dpr)
canvas.height = Math.floor(viewport.height * dpr)
canvas.style.width = '100%'
canvas.className = 'block mx-auto mb-2 bg-white shadow'
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr)
await page.render({ canvasContext: ctx, viewport }).promise
if (token !== renderToken) return
el.appendChild(canvas)
}
} catch (e) {
console.error('[PdfPreview] render error:', e?.message, e)
if (token === renderToken) {
error.value = "Impossible d'afficher le PDF."
loading.value = false
}
}
}
watch([() => props.file, () => props.url], render, { immediate: true })
onUnmounted(() => {
cleanup()
renderToken++
destroyTask()
})
</script>

View File

@@ -0,0 +1,132 @@
<template>
<div class="flex-shrink-0 border-b border-amber-300 bg-amber-50">
<!-- En-tête -->
<div class="flex items-center justify-between px-4 py-2">
<div class="flex items-center gap-2 text-sm">
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span v-if="diff.summary.total === 0" class="font-medium text-amber-800">
Nouvelle extraction aucune différence avec la version précédente
</span>
<span v-else class="font-medium text-amber-800">
Nouvelle extraction {{ diff.summary.total }} différence{{ diff.summary.total > 1 ? 's' : '' }}
<span class="text-amber-600 font-normal">
({{ diff.summary.metadata }} métadonnée{{ diff.summary.metadata > 1 ? 's' : '' }},
{{ diff.summary.locataires }} locataire{{ diff.summary.locataires > 1 ? 's' : '' }},
{{ diff.summary.operations }} opération{{ diff.summary.operations > 1 ? 's' : '' }})
</span>
</span>
</div>
<div class="flex items-center gap-2">
<button
@click="$emit('revert')"
class="px-2 py-1 text-xs text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded transition-colors"
title="Restaurer les données d'avant la re-extraction"
>
Revenir à la version précédente
</button>
<button
v-if="diff.summary.total > 0"
@click="open = !open"
class="px-2 py-1 text-xs text-amber-700 hover:text-amber-900 hover:bg-amber-100 rounded transition-colors"
>
{{ open ? 'Masquer le détail' : 'Voir le détail' }}
</button>
<button
@click="$emit('close')"
class="p-1 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded transition-colors"
title="Fermer"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Détail -->
<div v-if="open && diff.summary.total > 0" class="max-h-56 overflow-auto px-4 pb-3 space-y-3 text-xs">
<!-- Métadonnées -->
<div v-if="diff.metadata.length">
<div class="font-semibold text-amber-800 mb-1">Métadonnées</div>
<div class="space-y-0.5">
<div v-for="(c, i) in diff.metadata" :key="'m' + i" class="flex items-baseline gap-2">
<span class="text-gray-500 w-40 flex-shrink-0">{{ c.label }}</span>
<span class="text-red-600 line-through">{{ c.before }}</span>
<span class="text-gray-400"></span>
<span class="text-green-700 font-medium">{{ c.after }}</span>
</div>
</div>
</div>
<!-- Locataires -->
<div v-if="diff.locataires.length">
<div class="font-semibold text-amber-800 mb-1">Locataires</div>
<div class="space-y-1.5">
<div v-for="(loc, i) in diff.locataires" :key="'l' + i" class="bg-white/60 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(loc.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(loc.kind) }}
</span>
<span class="font-medium text-gray-700">{{ loc.title }}</span>
</div>
<div v-for="(f, j) in loc.fields" :key="j" class="flex items-baseline gap-2 pl-2">
<span class="text-gray-500 w-24 flex-shrink-0">{{ f.label }}</span>
<span class="text-red-600 line-through break-all">{{ f.before }}</span>
<span class="text-gray-400"></span>
<span class="text-green-700 font-medium break-all">{{ f.after }}</span>
</div>
</div>
</div>
</div>
<!-- Opérations -->
<div v-if="diff.operations.length">
<div class="font-semibold text-amber-800 mb-1">Opérations</div>
<div class="space-y-1.5">
<div v-for="(op, i) in diff.operations" :key="'o' + i" class="bg-white/60 rounded p-1.5">
<div class="flex items-center gap-2 mb-0.5">
<span :class="kindClass(op.kind)" class="text-[10px] uppercase font-semibold px-1 rounded">
{{ kindLabel(op.kind) }}
</span>
<span v-if="op.index >= 0" class="text-gray-500">opération #{{ op.index + 1 }}</span>
</div>
<div class="pl-2">
<div class="text-red-600 line-through break-all">{{ op.before }}</div>
<div class="text-green-700 font-medium break-all">{{ op.after }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
diff: {
type: Object,
required: true,
},
})
defineEmits(['revert', 'close'])
const open = ref(true)
function kindLabel(kind) {
if (kind === 'added') return 'ajouté'
if (kind === 'removed') return 'supprimé'
return 'modifié'
}
function kindClass(kind) {
if (kind === 'added') return 'bg-green-100 text-green-700'
if (kind === 'removed') return 'bg-red-100 text-red-700'
return 'bg-amber-100 text-amber-700'
}
</script>

View File

@@ -25,7 +25,24 @@
>
Annuler
</button>
<button
<button
v-if="!showTagging && extractedData"
@click="reExtract"
:disabled="isSaving || isReExtracting || !documentData?.has_pdf"
:title="documentData?.has_pdf ? 'Relancer l\'extraction depuis le PDF stocké' : 'PDF non disponible pour ce document'"
class="px-4 py-2 text-sm bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
<svg
class="w-4 h-4"
:class="{ 'animate-spin': isReExtracting }"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{{ isReExtracting ? 'Extraction…' : "Relancer l'extraction" }}
</button>
<button
v-if="!showTagging && extractedData"
@click="goToTagging"
:disabled="isSaving"
@@ -76,6 +93,19 @@
<!-- Droite : Édition ou Tagging -->
<div class="w-1/2 flex flex-col bg-gray-50">
<!-- Bandeau erreur de re-extraction -->
<div v-if="reExtractError" class="flex-shrink-0 px-4 py-2 bg-red-500/10 border-b border-red-500/30 text-sm text-red-600">
{{ reExtractError }}
</div>
<!-- Panneau des différences après re-extraction -->
<ReExtractionDiff
v-if="diff && !showTagging"
:diff="diff"
@revert="revertReExtract"
@close="diff = null"
/>
<!-- Vue 1 : JsonViewer (mode édition) -->
<JsonViewer
v-if="!showTagging && extractedData"
@@ -83,6 +113,7 @@
@update:data="extractedData = $event"
:is-loading="false"
:highlight="highlightInfo"
:diff="diff"
class="flex-1"
/>
@@ -116,6 +147,8 @@ import { useRouter, useRoute } from 'vue-router'
import PdfPreview from '../components/PdfPreview.vue'
import JsonViewer from '../components/JsonViewer.vue'
import TaggingStep from '../components/TaggingStep.vue'
import ReExtractionDiff from '../components/ReExtractionDiff.vue'
import { computeExtractionDiff } from '../utils/diffExtraction.js'
const router = useRouter()
const route = useRoute()
@@ -127,6 +160,10 @@ const isLoading = ref(true)
const showTagging = ref(false)
const isSaving = ref(false)
const saveError = ref(null)
const isReExtracting = ref(false)
const reExtractError = ref(null)
const diff = ref(null)
const previousData = ref(null)
const highlightInfo = computed(() => {
const q = route.query
@@ -183,6 +220,41 @@ function goToTagging() {
saveError.value = null
}
async function reExtract() {
isReExtracting.value = true
reExtractError.value = null
try {
const response = await fetch(`/api/documents/${documentId}/re-extract`, {
method: 'POST',
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.detail || 'Échec de la re-extraction')
}
// Sauvegarder l'état actuel pour pouvoir revenir en arrière.
previousData.value = JSON.parse(JSON.stringify(extractedData.value.data))
const newData = result.re_extracted_data
diff.value = computeExtractionDiff(previousData.value, newData)
// Remplacer les données de travail par la nouvelle extraction.
extractedData.value = { ...extractedData.value, data: newData }
} catch (err) {
console.error('Re-extraction error:', err)
reExtractError.value = err.message || 'Une erreur est survenue lors de la re-extraction'
} finally {
isReExtracting.value = false
}
}
function revertReExtract() {
if (!previousData.value) return
extractedData.value = { ...extractedData.value, data: previousData.value }
previousData.value = null
diff.value = null
}
async function handleSave(depensesTags, shouldOverwrite) {
isSaving.value = true
saveError.value = null

View File

@@ -0,0 +1,215 @@
// Comparaison de deux jeux de données d'extraction (avant / après re-extraction).
//
// Chaque jeu a la forme { metadata, situation_locataires, recapitulatif_operations }.
// Retourne un résumé structuré des différences, destiné à la fois à l'affichage
// (panneau de diff) et à la mise en évidence inline (anneaux « modifié »).
const EPS = 0.005
function numEq(a, b) {
return Math.abs((Number(a) || 0) - (Number(b) || 0)) < EPS
}
function valEq(a, b) {
if (a == null && b == null) return true
if (typeof a === 'number' || typeof b === 'number') {
// Comparer numériquement si les deux ressemblent à des nombres.
const na = Number(a)
const nb = Number(b)
if (!Number.isNaN(na) && !Number.isNaN(nb)) return numEq(na, nb)
}
return String(a ?? '') === String(b ?? '')
}
export function fmtValue(v) {
if (v == null || v === '') return '∅'
return String(v)
}
function get(obj, path) {
return path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj)
}
const METADATA_FIELDS = [
['editeur.nom', 'Éditeur · nom'],
['editeur.siret', 'Éditeur · SIRET'],
['editeur.adresse', 'Éditeur · adresse'],
['editeur.telephone', 'Éditeur · téléphone'],
['destinataire.nom', 'Destinataire · nom'],
['destinataire.adresse', 'Destinataire · adresse'],
['document.reference', 'Document · référence'],
['document.date', 'Document · date'],
['document.type', 'Document · type'],
['immeuble.code', 'Immeuble · code'],
['immeuble.adresse', 'Immeuble · adresse'],
['immeuble.ville', 'Immeuble · ville'],
['immeuble.code_postal', 'Immeuble · code postal'],
['solde.montant', 'Solde · montant'],
['solde.type', 'Solde · type'],
['solde.date_arrete', 'Solde · date arrêté'],
]
function ligneSig(l) {
if (!l) return '∅'
const p = l.periode || {}
const d = l.divers || {}
const per = p.debut || p.fin ? `${p.debut || '?'}${p.fin || '?'}` : '—'
const divers = d.montant ? ` D:${d.montant}${d.libelle ? '(' + d.libelle + ')' : ''}` : ''
return (
`${l.type || '?'} ${per} ` +
`L:${l.loyers || 0} T:${l.taxes || 0} P:${l.provisions || 0}${divers} ` +
`=${l.total || 0} R:${l.regles || 0} I:${l.impayes || 0}`
)
}
function diffLignes(before, after) {
const a = before || []
const b = after || []
const changes = []
const max = Math.max(a.length, b.length)
for (let i = 0; i < max; i++) {
const sa = i < a.length ? ligneSig(a[i]) : null
const sb = i < b.length ? ligneSig(b[i]) : null
if (sa !== sb) {
changes.push({ label: `Ligne ${i + 1}`, before: fmtValue(sa), after: fmtValue(sb) })
}
}
return changes
}
function diffLocataire(before, after) {
const fields = []
const scalar = [
['locataire.nom', 'Nom'],
['lot.numero', 'Lot'],
['lot.type', 'Type'],
['totaux.loyers', 'Total loyers'],
['totaux.taxes', 'Total taxes'],
['totaux.provisions', 'Total provisions'],
['totaux.divers', 'Total divers'],
['totaux.solde_anterieur', 'Solde antérieur'],
['totaux.total', 'Total'],
['totaux.regles', 'Réglés'],
['totaux.impayes', 'Impayés'],
]
for (const [path, label] of scalar) {
const bv = get(before, path)
const av = get(after, path)
if (!valEq(bv, av)) {
fields.push({ label, before: fmtValue(bv), after: fmtValue(av) })
}
}
fields.push(...diffLignes(before?.lignes, after?.lignes))
return fields
}
function locataireTitle(loc) {
if (!loc) return '?'
const lot = loc.lot?.numero ? `Lot ${loc.lot.numero}` : 'Lot ?'
const nom = loc.locataire?.nom || 'sans nom'
return `${lot}${nom}`
}
function opSig(op) {
if (!op) return '∅'
const m = op.montants || {}
return (
`${op.categorie || '?'} | ${op.fournisseur || '?'} | ${op.description || ''} | ` +
`d:${m.debit || 0} c:${m.credit || 0} tva:${m.tva || 0} ` +
`loc:${m.locatif || 0} ded:${m.deductible || 0}`
)
}
// Compare deux extractions et retourne le diff structuré.
export function computeExtractionDiff(before, after) {
const b = before || {}
const a = after || {}
// --- Métadonnées ---
const metadata = []
const changedMetadataPaths = []
for (const [path, label] of METADATA_FIELDS) {
const bv = get(b.metadata, path)
const av = get(a.metadata, path)
if (!valEq(bv, av)) {
metadata.push({ label, before: fmtValue(bv), after: fmtValue(av) })
changedMetadataPaths.push(path)
}
}
// --- Locataires (alignés par index : même PDF, même ordre) ---
const locataires = []
const changedLocataireIndices = []
const lb = b.situation_locataires || []
const la = a.situation_locataires || []
const maxLoc = Math.max(lb.length, la.length)
for (let i = 0; i < maxLoc; i++) {
const ob = i < lb.length ? lb[i] : null
const oa = i < la.length ? la[i] : null
if (!ob && oa) {
locataires.push({ index: i, kind: 'added', title: locataireTitle(oa), fields: [] })
changedLocataireIndices.push(i)
} else if (ob && !oa) {
locataires.push({ index: i, kind: 'removed', title: locataireTitle(ob), fields: [] })
} else {
const fields = diffLocataire(ob, oa)
if (fields.length) {
locataires.push({ index: i, kind: 'modified', title: locataireTitle(oa), fields })
changedLocataireIndices.push(i)
}
}
}
// --- Opérations (alignées par contenu, pas par index) ---
// Les opérations peuvent changer d'ordre ou de nombre entre deux extractions ;
// on compare donc par signature (multiset) plutôt que position par position.
const operations = []
const changedOperationIndices = []
const ob = b.recapitulatif_operations || []
const oa = a.recapitulatif_operations || []
const oldCounts = new Map()
for (const op of ob) {
const s = opSig(op)
oldCounts.set(s, (oldCounts.get(s) || 0) + 1)
}
// Nouvelles opérations absentes de l'ancienne extraction -> à surligner.
const newCounts = new Map()
oa.forEach((op, i) => {
const s = opSig(op)
const remaining = oldCounts.get(s) || 0
if (remaining > 0) {
oldCounts.set(s, remaining - 1) // appariée avec une ancienne identique
} else {
changedOperationIndices.push(i)
operations.push({ index: i, kind: 'added', before: '∅', after: fmtValue(s) })
}
newCounts.set(s, (newCounts.get(s) || 0) + 1)
})
// Anciennes opérations absentes de la nouvelle extraction -> supprimées.
const seen = new Map()
ob.forEach((op) => {
const s = opSig(op)
seen.set(s, (seen.get(s) || 0) + 1)
if ((newCounts.get(s) || 0) < seen.get(s)) {
operations.push({ index: -1, kind: 'removed', before: fmtValue(s), after: '∅' })
}
})
const summary = {
metadata: metadata.length,
locataires: locataires.length,
operations: operations.length,
total: metadata.length + locataires.length + operations.length,
}
return {
metadata,
locataires,
operations,
changedMetadataPaths,
changedLocataireIndices,
changedOperationIndices,
summary,
}
}

9
packaging/app_entry.py Normal file
View File

@@ -0,0 +1,9 @@
"""Point d'entrée de l'exécutable empaqueté (PyInstaller).
Lance l'application en mode bureau (fenêtre native).
"""
from plesna_gerance.desktop import run
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,53 @@
# Build de l'application Windows autonome (Plesna Gérance).
#
# À lancer depuis la racine du projet, dans PowerShell, sur une machine Windows :
#
# powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1
#
# Prérequis sur la machine de build (PAS sur la machine de l'utilisateur final) :
# - Python >= 3.10 et uv (https://docs.astral.sh/uv/)
# - Node.js >= 22 (pour builder le frontend)
# - (optionnel) Inno Setup 6 pour produire l'installeur .exe
#
# Résultat :
# dist\PlesnaGerance.exe (exécutable autonome)
# dist\PlesnaGerance-Setup.exe (installeur, si Inno Setup présent)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
Set-Location $Root
Write-Host "==> 1/4 Build du frontend" -ForegroundColor Cyan
Set-Location (Join-Path $Root "frontend")
npm ci
npm run build
Set-Location $Root
Write-Host "==> 2/4 Installation des dependances Python (desktop + build)" -ForegroundColor Cyan
uv sync --group desktop --group build
Write-Host "==> 3/4 Empaquetage PyInstaller" -ForegroundColor Cyan
uv run pyinstaller packaging\plesna_gerance.spec --noconfirm --clean
$Exe = Join-Path $Root "dist\PlesnaGerance.exe"
if (-not (Test-Path $Exe)) {
throw "Echec : $Exe introuvable."
}
Write-Host " OK -> $Exe" -ForegroundColor Green
Write-Host "==> 4/4 Construction de l'installeur (Inno Setup)" -ForegroundColor Cyan
$IsccCandidates = @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"${env:ProgramFiles}\Inno Setup 6\ISCC.exe"
)
$Iscc = $IsccCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($Iscc) {
& $Iscc "packaging\installer.iss"
Write-Host " OK -> dist\PlesnaGerance-Setup.exe" -ForegroundColor Green
} else {
Write-Host " Inno Setup non trouve : etape installeur ignoree." -ForegroundColor Yellow
Write-Host " (L'executable dist\PlesnaGerance.exe est utilisable tel quel.)" -ForegroundColor Yellow
}
Write-Host "`nTermine." -ForegroundColor Green

45
packaging/installer.iss Normal file
View File

@@ -0,0 +1,45 @@
; Script Inno Setup pour Plesna Gérance.
; Compile avec : ISCC.exe packaging\installer.iss
;
; Installation par utilisateur (pas de droits administrateur requis) :
; idéal pour un poste personnel. Crée un raccourci Bureau et menu Démarrer.
#define AppName "Plesna Gérance"
#define AppVersion "0.1.0"
#define AppPublisher "Plesna"
#define AppExeName "PlesnaGerance.exe"
[Setup]
AppId={{B2F8C0E2-7A3D-4C1E-9E2A-PLESNAGERANCE}}
AppName={#AppName}
AppVersion={#AppVersion}
AppPublisher={#AppPublisher}
DefaultDirName={autopf}\PlesnaGerance
DefaultGroupName={#AppName}
DisableProgramGroupPage=yes
; Installation par utilisateur courant -> aucun prompt UAC administrateur.
PrivilegesRequired=lowest
OutputDir=..\dist
OutputBaseFilename=PlesnaGerance-Setup
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; Données utilisateur (DB, documents) -> %APPDATA%\PlesnaGerance, conservées
; à la désinstallation (voir paths.get_data_dir).
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
[Tasks]
Name: "desktopicon"; Description: "Créer un raccourci sur le Bureau"; GroupDescription: "Raccourcis :"; Flags: checkedonce
[Files]
Source: "..\dist\PlesnaGerance.exe"; DestDir: "{app}"; Flags: ignoreversion
[Icons]
Name: "{group}\{#AppName}"; Filename: "{app}\{#AppExeName}"
Name: "{group}\Désinstaller {#AppName}"; Filename: "{uninstallexe}"
Name: "{userdesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#AppExeName}"; Description: "Lancer {#AppName}"; Flags: nowait postinstall skipifsilent

View File

@@ -0,0 +1,83 @@
# -*- mode: python ; coding: utf-8 -*-
"""Spec PyInstaller pour Plesna Gérance (application de bureau autonome).
À lancer depuis la racine du projet :
pyinstaller packaging/plesna_gerance.spec
Produit un exécutable unique (onefile, sans console) qui embarque :
- le code Python et ses dépendances (FastAPI, uvicorn, pdfplumber, pywebview) ;
- le frontend déjà buildé (``frontend/dist``).
Le frontend doit être buildé AVANT (``cd frontend && npm run build``).
"""
from pathlib import Path
from PyInstaller.utils.hooks import collect_all, collect_submodules
# Racine du projet (le .spec est dans packaging/).
ROOT = Path(SPECPATH).resolve().parent
# --- Ressources embarquées ------------------------------------------------
datas = [
# Frontend buildé -> extrait dans _MEIPASS/frontend/dist (cf. paths.resource_path)
(str(ROOT / "frontend" / "dist"), "frontend/dist"),
]
binaries = []
hiddenimports = []
# Dépendances à embarquer en totalité (modules dynamiques mal détectés).
for pkg in ("uvicorn", "pdfplumber", "pdfminer", "pypdfium2"):
pkg_datas, pkg_binaries, pkg_hidden = collect_all(pkg)
datas += pkg_datas
binaries += pkg_binaries
hiddenimports += pkg_hidden
# uvicorn charge ses protocoles/boucles par import dynamique.
hiddenimports += collect_submodules("uvicorn")
block_cipher = None
a = Analysis(
[str(ROOT / "packaging" / "app_entry.py")],
pathex=[str(ROOT / "src")],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["tkinter"],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="PlesnaGerance",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False, # application fenêtrée, pas de console
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=str(ROOT / "packaging" / "icon.ico")
if (ROOT / "packaging" / "icon.ico").exists()
else None,
)

View File

@@ -10,11 +10,62 @@ dependencies = [
"python-multipart>=0.0.6",
"sqlalchemy>=2.0.0",
"httpx>=0.24.0",
"pdfplumber>=0.11.9",
]
[project.scripts]
plesna-gerance = "plesna_gerance.cli:main"
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.6",
]
# Mode application de bureau (fenêtre native). Sous Windows, pywebview
# s'appuie sur WebView2 (Edge Chromium), présent par défaut sur Windows 10/11.
desktop = [
"pywebview>=5.0",
]
# Backend de rendu pour tester le mode bureau SOUS LINUX (Qt, pip-installable).
# Inutile sous Windows (WebView2) : ne pas inclure dans le build empaqueté.
desktop-linux = [
"pywebview>=5.0",
"qtpy>=2.4",
"PyQt6>=6.6",
"PyQt6-WebEngine>=6.6",
]
# Outils pour produire l'exécutable autonome (à lancer sur Windows).
build = [
"pyinstaller>=6.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-q"
[tool.ruff]
target-version = "py310"
line-length = 88
src = ["src", "tests"]
[tool.ruff.lint]
# E/W: pycodestyle, F: pyflakes, I: isort, UP: pyupgrade, B: bugbear
select = ["E", "W", "F", "I", "UP", "B"]
ignore = [
"E501", # longueur de ligne gérée par le formateur, pas bloquante
"B904", # raise sans `from` : on traduit volontairement les exceptions en HTTP
]
[tool.ruff.lint.flake8-bugbear]
# Appels idiomatiques FastAPI en argument par défaut (injection de dépendances)
extend-immutable-calls = [
"fastapi.Depends",
"fastapi.File",
"fastapi.Form",
"fastapi.Query",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

View File

@@ -1,6 +1,7 @@
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
from pathlib import Path
import mimetypes
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.responses import FileResponse
@@ -8,17 +9,30 @@ from fastapi.staticfiles import StaticFiles
from .. import __version__
from ..database import init_db
from ..paths import resource_path
from .routes import (
extraction_router,
documents_router,
tags_router,
analytics_router,
dashboard_router,
revenus_router,
ia_router,
config_router,
dashboard_router,
documents_router,
extraction_router,
ia_router,
revenus_router,
tags_router,
)
# Garantit le bon type MIME pour les modules ES (worker PDF.js notamment),
# indépendamment du registre système (Windows peut ne pas connaître .mjs).
mimetypes.add_type("text/javascript", ".mjs")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize database on application startup."""
init_db()
yield
app = FastAPI(
title="Plesna Gérance API",
description="API pour extraire les informations structurées des PDFs de comptes rendus de gérance Oralia/ICS.",
@@ -26,16 +40,10 @@ app = FastAPI(
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
# Initialize database on startup
@app.on_event("startup")
async def startup_event():
"""Initialize database on application startup."""
init_db()
# Include routers
app.include_router(extraction_router)
app.include_router(documents_router)
@@ -64,8 +72,8 @@ async def health() -> dict:
return {"status": "ok"}
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
# Determine the frontend dist path (works both from source and when packaged)
FRONTEND_DIST = resource_path("frontend", "dist")
# Mount static files for production (if dist exists)
@@ -74,12 +82,14 @@ if FRONTEND_DIST.exists():
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
# Catch-all route for SPA - must be last
_dist_root = FRONTEND_DIST.resolve()
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
"""Serve the SPA for all non-API routes."""
# If requesting a file that exists, serve it
file_path = FRONTEND_DIST / full_path
if file_path.is_file():
# If requesting a file that exists (and stays within dist), serve it
file_path = (FRONTEND_DIST / full_path).resolve()
if file_path.is_file() and file_path.is_relative_to(_dist_root):
return FileResponse(file_path)
# Otherwise serve index.html for SPA routing
return FileResponse(FRONTEND_DIST / "index.html")
return FileResponse(_dist_root / "index.html")

View File

@@ -1,13 +1,13 @@
"""API Routes package."""
from .extraction import router as extraction_router
from .documents import router as documents_router
from .tags import router as tags_router
from .analytics import router as analytics_router
from .dashboard import router as dashboard_router
from .revenus import router as revenus_router
from .ia import router as ia_router
from .config import router as config_router
from .dashboard import router as dashboard_router
from .documents import router as documents_router
from .extraction import router as extraction_router
from .ia import router as ia_router
from .revenus import router as revenus_router
from .tags import router as tags_router
__all__ = [
"extraction_router",

View File

@@ -1,25 +1,25 @@
"""Analytics routes - Data analysis and reporting endpoints."""
from datetime import date
from collections import defaultdict
from datetime import date
from fastapi import APIRouter, Depends, Query
from sqlalchemy import distinct, func, select
from sqlalchemy.orm import Session
from sqlalchemy import select, func, distinct, extract
from ...database import get_session
from ...database.models import Depense, Document, Immeuble, Lot, Tag
from ..schemas.models import (
ImmeubleResponse,
LotResponse,
CategorySummary,
DepenseDetail,
DepensesSummary,
CategorySummary,
TagSummary,
MonthlySummary,
FournisseurSummary,
FournisseurResponse,
FournisseurSummary,
ImmeubleResponse,
LotResponse,
MonthlySummary,
TagResponse,
TagSummary,
)
router = APIRouter(prefix="/api", tags=["analytics"])

View File

@@ -4,13 +4,13 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ...database import get_session, DatabaseService
from ...database import DatabaseService, get_session
from ...database.models import Tag
from ...services.settings_service import (
SETTINGS_REGISTRY,
delete_setting,
get_all_settings,
set_setting,
delete_setting,
SETTINGS_REGISTRY,
)
router = APIRouter(prefix="/api/config", tags=["config"])
@@ -67,7 +67,7 @@ async def reset_setting(
"""Reset un setting au défaut (supprime l'override DB)."""
if key not in SETTINGS_REGISTRY:
raise HTTPException(status_code=404, detail=f"Setting inconnu : {key}")
deleted = delete_setting(session, key)
delete_setting(session, key)
# Return the resolved value after deletion
all_settings = get_all_settings(session)
return all_settings[key]

View File

@@ -1,21 +1,21 @@
"""Dashboard routes - Aggregated data for the home page."""
from datetime import date, timedelta
from collections import defaultdict
from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import select, func, desc
from pydantic import BaseModel
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import (
Depense,
Document,
Immeuble,
Lot,
Locataire,
Lot,
Revenu,
Depense,
)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])

View File

@@ -1,21 +1,25 @@
"""Documents routes - CRUD operations for documents."""
import json
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile, Form
from fastapi.responses import Response, JSONResponse
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...database import get_session, DatabaseService, storage
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
from ...database import DatabaseService, get_session, storage
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
from ...database.service import DuplicateDocumentError
from ...extractor import extract_compte_rendu
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
from ...utils.uploads import UploadTooLargeError, read_upload_limited
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
router = APIRouter(prefix="/api", tags=["documents"])
logger = logging.getLogger(__name__)
@router.post("/save", response_model=SaveResponse)
async def save_document(
@@ -60,9 +64,10 @@ async def save_document(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
except Exception:
logger.exception("Erreur lors de la sauvegarde du document")
raise HTTPException(
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
status_code=500, detail="Erreur lors de la sauvegarde du document."
)
@@ -99,9 +104,11 @@ async def save_document_with_pdf(
status_code=400, detail=f"JSON invalide pour depenses_tags: {e}"
)
# Read PDF content
# Read PDF content (taille bornée)
try:
pdf_content = await pdf_file.read()
pdf_content = await read_upload_limited(pdf_file)
except UploadTooLargeError as e:
raise HTTPException(status_code=413, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")
@@ -134,9 +141,10 @@ async def save_document_with_pdf(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
except Exception:
logger.exception("Erreur lors de la sauvegarde du document avec PDF")
raise HTTPException(
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
status_code=500, detail="Erreur lors de la sauvegarde du document."
)

View File

@@ -7,6 +7,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from ...extractor import extract_compte_rendu
from ...utils.uploads import UploadTooLargeError, read_upload_limited
router = APIRouter(prefix="/api", tags=["extraction"])
@@ -47,10 +48,15 @@ async def extract_pdf(
tmp_path: Path | None = None
# Lecture bornée du contenu uploadé
try:
content = await read_upload_limited(file)
except UploadTooLargeError as e:
raise HTTPException(status_code=413, detail=str(e))
# Sauvegarde temporaire du fichier uploadé
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
content = await file.read()
tmp_file.write(content)
tmp_path = Path(tmp_file.name)

View File

@@ -2,11 +2,10 @@
import logging
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import httpx
from ...services.ollama_service import ask_ollama, check_ollama_health
logger = logging.getLogger(__name__)
@@ -76,9 +75,11 @@ async def ask_ia(request: AskRequest) -> AskResponse:
status_code=503,
detail=f"Erreur Ollama : {e.response.status_code}",
)
except Exception as e:
logger.error(f"IA ask error: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
except Exception:
logger.exception("IA ask error")
raise HTTPException(
status_code=500, detail="Erreur interne lors du traitement de la question."
)
@router.get("/health", response_model=HealthResponse)

View File

@@ -1,19 +1,18 @@
"""Revenus routes - Dedicated endpoints for rental income analytics."""
from datetime import date, timedelta
from collections import defaultdict
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import select, func, desc, and_
from pydantic import BaseModel
from sqlalchemy import and_, desc, func, select
from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import (
Document,
Immeuble,
Lot,
Locataire,
Lot,
Revenu,
)

View File

@@ -3,7 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ...database import get_session, DatabaseService
from ...database import DatabaseService, get_session
from ...services.tag_predictor import TagPredictor
from ..schemas import PredictTagsRequest

View File

@@ -1,14 +1,14 @@
"""Pydantic schemas for API request/response models."""
from .models import (
SaveRequest,
SaveResponse,
PredictTagsRequest,
DocumentSummary,
DepenseDetail,
DepensesSummary,
DocumentSummary,
ImmeubleResponse,
LotResponse,
PredictTagsRequest,
SaveRequest,
SaveResponse,
)
__all__ = [

View File

@@ -5,7 +5,6 @@ from typing import Any
from pydantic import BaseModel
# ============================================================
# Requests
# ============================================================

View File

@@ -153,11 +153,35 @@ def serve(host: str, port: int, reload: bool) -> None:
)
@main.command()
def desktop() -> None:
"""Lance l'application en fenetre native (application de bureau).
Demarre le serveur en arriere-plan et ouvre une fenetre dediee.
C'est le mode utilise par l'executable Windows empaquete.
Necessite la dependance optionnelle 'desktop' (pywebview):
uv sync --group desktop
"""
try:
import webview # noqa: F401
except ImportError as e:
raise click.ClickException(
"Dependance manquante pour le mode bureau (pywebview). "
"Installez-la avec : uv sync --group desktop"
) from e
from .desktop import run
run()
@main.command()
@click.option(
"--db-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Chemin de la base de donnees (defaut: ~/.plesna_gerance/database.sqlite)",
help="Chemin de la base de donnees (defaut: dossier de donnees utilisateur)",
)
def init_db(db_path: Path | None) -> None:
"""Initialise la base de donnees SQLite.
@@ -188,10 +212,12 @@ def db_info(db_path: Path | None) -> None:
Montre le nombre de documents, immeubles, lots, etc.
"""
from .database import init_db as do_init_db, get_session_factory
from .database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
from sqlalchemy import func, select
from .database import get_session_factory
from .database import init_db as do_init_db
from .database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
# Initialize/connect to database
result_path = do_init_db(db_path)
SessionLocal = get_session_factory()
@@ -206,8 +232,8 @@ def db_info(db_path: Path | None) -> None:
dep_count = session.execute(select(func.count(Depense.id))).scalar()
click.echo(f"Base de donnees: {result_path}")
click.echo(f"")
click.echo(f"Statistiques:")
click.echo("")
click.echo("Statistiques:")
click.echo(f" - Documents: {doc_count}")
click.echo(f" - Immeubles: {imm_count}")
click.echo(f" - Lots: {lot_count}")

View File

@@ -1,9 +1,9 @@
"""Database module for plesna-gerance."""
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense, Setting
from .service import DatabaseService, DuplicateDocumentError
from . import storage
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Depense, Document, Immeuble, Locataire, Lot, Revenu, Setting
from .service import DatabaseService, DuplicateDocumentError
__all__ = [
"get_engine",

View File

@@ -1,39 +1,44 @@
"""Database connection management for SQLite."""
import os
from collections.abc import Generator
from pathlib import Path
from typing import Generator
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from ..paths import get_data_dir
from .models import Base, Tag
# Default database path - relative to project root
def _get_project_root() -> Path:
"""Find project root by looking for pyproject.toml."""
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
return parent
# Fallback to home directory if not found
return Path.home() / ".plesna_gerance"
DEFAULT_DB_PATH = _get_project_root() / "data" / "database.sqlite"
# Global engine instance
_engine = None
_SessionLocal = None
def get_db_path() -> Path:
"""Get database path from environment or default."""
"""Get database path from environment or default.
Resolved lazily so a packaged build writes to the per-user data dir.
"""
env_path = os.environ.get("PLESNA_DB_PATH")
if env_path:
return Path(env_path)
return DEFAULT_DB_PATH
return get_data_dir() / "database.sqlite"
def _build_engine(db_path: Path):
"""Create a SQLAlchemy engine for the given SQLite path.
Single source of truth for engine configuration.
"""
# Create parent directory if needed
db_path.parent.mkdir(parents=True, exist_ok=True)
return create_engine(
f"sqlite:///{db_path}",
echo=False, # Set to True for SQL debugging
connect_args={"check_same_thread": False}, # Required for FastAPI
)
def get_engine(db_path: Path | None = None):
@@ -41,18 +46,7 @@ def get_engine(db_path: Path | None = None):
global _engine
if _engine is None:
if db_path is None:
db_path = get_db_path()
# Create parent directory if needed
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create engine with SQLite
_engine = create_engine(
f"sqlite:///{db_path}",
echo=False, # Set to True for SQL debugging
connect_args={"check_same_thread": False}, # Required for FastAPI
)
_engine = _build_engine(db_path or get_db_path())
return _engine
@@ -87,25 +81,13 @@ def init_db(db_path: Path | None = None) -> Path:
if db_path is None:
db_path = get_db_path()
# Reset globals to use new path
global _engine, _SessionLocal
_engine = None
_SessionLocal = None
# Create parent directory
db_path.parent.mkdir(parents=True, exist_ok=True)
# Create engine and tables
engine = create_engine(
f"sqlite:///{db_path}", echo=False, connect_args={"check_same_thread": False}
)
# Reset globals so the engine/session factory rebuild against db_path
reset_connection()
# Build the engine (reuses the shared configuration) and create tables
engine = get_engine(db_path)
Base.metadata.create_all(bind=engine)
# Update globals
_engine = engine
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Seed predefined tags if the table is empty
_seed_tags_if_empty(engine)

View File

@@ -1,23 +1,27 @@
"""SQLAlchemy models for plesna-gerance database."""
from datetime import date, datetime
from typing import Optional
from datetime import datetime, timezone
from sqlalchemy import (
Column,
Integer,
String,
Float,
Date,
DateTime,
Text,
Float,
ForeignKey,
UniqueConstraint,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase, relationship
def _utcnow() -> datetime:
"""Horodatage UTC timezone-aware (remplace datetime.utcnow déprécié)."""
return datetime.now(timezone.utc)
class Base(DeclarativeBase):
"""Base class for all models."""
@@ -31,7 +35,7 @@ class Tag(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
nom = Column(String(100), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
# Relations
depenses = relationship("Depense", back_populates="tag")
@@ -48,7 +52,7 @@ class Setting(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
def __repr__(self) -> str:
return f"<Setting(key={self.key}, value={self.value})>"
@@ -64,7 +68,7 @@ class Immeuble(Base):
adresse = Column(String(255), nullable=True)
ville = Column(String(100), nullable=True)
code_postal = Column(String(10), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
# Relations
lots = relationship("Lot", back_populates="immeuble", cascade="all, delete-orphan")
@@ -84,7 +88,7 @@ class Lot(Base):
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
numero = Column(String(10), nullable=False)
type = Column(String(50), nullable=True) # "Loc. Commercial", "Appartement", etc.
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
# Contrainte unique: un numéro de lot par immeuble
__table_args__ = (
@@ -114,7 +118,7 @@ class Locataire(Base):
nom = Column(String(255), nullable=False)
date_debut = Column(Date, nullable=True) # Date d'entrée dans le lot
date_fin = Column(Date, nullable=True) # Date de sortie (NULL si actif)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
# Contrainte unique: un locataire par lot et période
__table_args__ = (
@@ -156,7 +160,7 @@ class Document(Base):
solde_type = Column(String(20), nullable=True) # "crediteur" ou "debiteur"
solde_date_arrete = Column(Date, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
# Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH)
pdf_path = Column(
@@ -211,7 +215,7 @@ class Revenu(Base):
regles = Column(Float, default=0.0) # Montant réglé
impayes = Column(Float, default=0.0)
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
Index("ix_revenu_document", "document_id"),
@@ -256,7 +260,7 @@ class Depense(Base):
locatif = Column(Float, default=0.0) # Part locative
deductible = Column(Float, default=0.0) # Part déductible
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
__table_args__ = (
Index("ix_depense_document", "document_id"),

View File

@@ -6,10 +6,10 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
from ..utils.amounts import parse_amount
from . import storage
from .models import Depense, Document, Immeuble, Locataire, Lot, Revenu, Tag
class DuplicateDocumentError(Exception):
@@ -95,6 +95,21 @@ class DatabaseService:
except (ValueError, TypeError):
return None
@staticmethod
def _normalize_amount(value: Any) -> float | None:
"""Normalise un montant en float (ou None si non interprétable).
Garantit qu'un type non numérique issu de l'extraction (string, dict…)
n'entre jamais en base dans une colonne Float.
"""
if isinstance(value, bool): # bool est un int en Python, on l'exclut
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
return parse_amount(value)
return None
def save_document(
self,
data: dict[str, Any],
@@ -186,7 +201,7 @@ class DatabaseService:
json_data=json.dumps(data, ensure_ascii=False, default=str),
editeur_nom=editeur_info.get("nom"),
editeur_siret=editeur_info.get("siret"),
solde_montant=solde_info.get("montant"),
solde_montant=self._normalize_amount(solde_info.get("montant")),
solde_type=solde_info.get("type"),
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
pdf_path=pdf_path,

View File

@@ -7,14 +7,7 @@ from datetime import date
from pathlib import Path
from typing import Any
def _get_project_root() -> Path:
"""Find project root by looking for pyproject.toml."""
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
return parent
return Path.home() / ".plesna_gerance"
from ..paths import get_data_dir
def get_storage_root() -> Path:
@@ -26,7 +19,7 @@ def get_storage_root() -> Path:
env_path = os.environ.get("PLESNA_STORAGE_PATH")
if env_path:
return Path(env_path)
return _get_project_root() / "data" / "documents"
return get_data_dir() / "documents"
def extract_street_letter(adresse: str | None) -> str:
@@ -240,7 +233,7 @@ def read_json(relative_path: str, storage_root: Path | None = None) -> dict[str,
FileNotFoundError: If file doesn't exist.
"""
full_path = get_absolute_path(relative_path, storage_root)
with open(full_path, "r", encoding="utf-8") as f:
with open(full_path, encoding="utf-8") as f:
return json.load(f)

View File

@@ -0,0 +1,87 @@
"""Lanceur application de bureau (fenêtre native via pywebview).
Démarre le serveur FastAPI en arrière-plan sur un port libre de la boucle
locale, attend qu'il réponde, puis ouvre une fenêtre native pointant dessus.
C'est le point d'entrée de l'exécutable empaqueté (PyInstaller).
``pywebview`` est importé paresseusement : le reste du paquet reste utilisable
sans cette dépendance (serveur headless, Docker, CI).
"""
import os
import socket
import threading
import httpx
import uvicorn
from .api import app
WINDOW_TITLE = "Plesna Gérance"
HOST = "127.0.0.1"
def _find_free_port() -> int:
"""Réserve un port TCP libre sur la boucle locale."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, 0))
return s.getsockname()[1]
class _ThreadedServer(uvicorn.Server):
"""Serveur uvicorn lançable dans un thread (sans handlers de signaux)."""
def install_signal_handlers(self) -> None: # noqa: D102 - voir docstring classe
pass
def _wait_until_ready(base_url: str, timeout: float = 30.0) -> bool:
"""Attend que ``/api/health`` réponde, jusqu'à ``timeout`` secondes."""
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
resp = httpx.get(f"{base_url}/api/health", timeout=1.0)
if resp.status_code == 200:
return True
except httpx.HTTPError:
pass
time.sleep(0.2)
return False
def run() -> None:
"""Lance le serveur puis la fenêtre native. Bloque jusqu'à fermeture."""
port = _find_free_port()
base_url = f"http://{HOST}:{port}"
config = uvicorn.Config(app, host=HOST, port=port, log_level="warning")
server = _ThreadedServer(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
if not _wait_until_ready(base_url):
server.should_exit = True
raise RuntimeError(
"Le serveur n'a pas démarré à temps. "
"Consultez les journaux pour plus de détails."
)
# Import paresseux : pywebview n'est requis que pour le mode bureau.
import webview
# PLESNA_DEBUG=1 active l'inspecteur web (DevTools) dans la fenetre native :
# clic droit -> « Inspecter » (ou « Inspect element ») pour ouvrir la console.
debug = os.environ.get("PLESNA_DEBUG", "").lower() in ("1", "true", "yes")
webview.create_window(WINDOW_TITLE, base_url, width=1280, height=860)
try:
webview.start(debug=debug)
finally:
# Fermeture de la fenêtre -> arrêt propre du serveur.
server.should_exit = True
if __name__ == "__main__":
run()

View File

@@ -1,9 +1,15 @@
"""Orchestrateur principal pour l'extraction des comptes rendus de gérance."""
from .parsers.pdf import extract_text_from_pdf
from .parsers.metadata import extract_metadata
import logging
from .parsers.locataires import extract_situation_locataires
from .parsers.locataires_table import extract_situation_locataires_from_pdf
from .parsers.metadata import extract_metadata
from .parsers.operations import extract_recapitulatif_operations
from .parsers.operations_table import extract_recapitulatif_operations_from_pdf
from .parsers.pdf import read_pdf
logger = logging.getLogger(__name__)
def extract_compte_rendu(pdf_path: str) -> dict:
@@ -22,13 +28,32 @@ def extract_compte_rendu(pdf_path: str) -> dict:
- recapitulatif_operations: dépenses et opérations par catégorie
Raises:
subprocess.CalledProcessError: Si pdftotext échoue
FileNotFoundError: Si le fichier PDF n'existe pas
"""
text = extract_text_from_pdf(pdf_path)
content = read_pdf(pdf_path)
# Extraction des locataires par cellules de tableau (géométrique) : robuste aux
# colonnes vides et aux lignes mal alignées. Repli sur l'ancien parseur texte si
# le tableau n'a pas de filets détectables ou en cas d'erreur inattendue.
try:
situation = extract_situation_locataires_from_pdf(pdf_path)
except Exception:
logger.exception("Extraction locataires par cellules échouée, repli sur le parseur texte")
situation = []
if not situation:
situation = extract_situation_locataires(content.text)
# Opérations par cellules de tableau, même repli sur le parseur texte.
try:
operations = extract_recapitulatif_operations_from_pdf(pdf_path)
except Exception:
logger.exception("Extraction opérations par cellules échouée, repli sur le parseur texte")
operations = []
if not operations:
operations = extract_recapitulatif_operations(content.text)
return {
"metadata": extract_metadata(text),
"situation_locataires": extract_situation_locataires(text),
"recapitulatif_operations": extract_recapitulatif_operations(text),
"metadata": extract_metadata(content.text, content.words),
"situation_locataires": situation,
"recapitulatif_operations": operations,
}

View File

@@ -1,11 +1,13 @@
"""Parsers pour les différentes sections des PDFs de gérance."""
from .pdf import extract_text_from_pdf
from .metadata import extract_metadata
from .locataires import extract_situation_locataires
from .metadata import extract_metadata
from .operations import extract_recapitulatif_operations
from .pdf import PdfContent, extract_text_from_pdf, read_pdf
__all__ = [
"PdfContent",
"read_pdf",
"extract_text_from_pdf",
"extract_metadata",
"extract_situation_locataires",

View File

@@ -2,8 +2,8 @@
import re
from ..utils.dates import parse_french_date
from ..utils.amounts import extract_amounts_from_line
from ..utils.dates import parse_french_date
def _preprocess_locataires_text(text: str) -> str:
@@ -21,7 +21,6 @@ def _preprocess_locataires_text(text: str) -> str:
lines = text.split("\n")
cleaned_lines = []
first_situation_found = False
in_situation_section = False
# Patterns à ignorer (en-têtes répétés sur chaque page)
skip_patterns = [
@@ -64,7 +63,6 @@ def _preprocess_locataires_text(text: str) -> str:
if "SITUATION DES LOCATAIRES" in stripped:
if not first_situation_found:
first_situation_found = True
in_situation_section = True
cleaned_lines.append(line)
# Ignorer les occurrences suivantes
continue

View File

@@ -0,0 +1,322 @@
"""Extraction de la situation des locataires par cellules de tableau (géométrique).
Contrairement à :func:`plesna_gerance.parsers.locataires.extract_situation_locataires`
(qui parse le texte mis en page avec des regex de position), cette implémentation
reconstruit chaque **ligne visuelle** à partir des coordonnées des mots (regroupement
par ``y``) et affecte chaque valeur à **sa colonne** via les filets du tableau
(bandes ``x`` déduites de la ligne d'en-tête).
Avantages sur l'approche texte + regex :
- robuste aux **cellules vides** (pas de décalage d'indices : un montant est lu
dans la bande de sa colonne, pas au n-ième rang de la ligne) ;
- robuste aux **lignes mal alignées** (les PDF Oralia n'ont pas de baseline nette :
le regroupement par ``y`` tolérant recompose la ligne comme le ferait l'œil) ;
- ignore d'office le **texte hors des filets** (logo/adresse/en-tête réimprimé en
haut de page), qui polluait la détection du nom de locataire.
La sortie est **identique** en structure à ``extract_situation_locataires`` pour
rester interchangeable (mêmes clés ``lot`` / ``locataire`` / ``lignes`` / ``totaux``).
"""
import re
from unicodedata import normalize as _normalize
import pdfplumber
from ..utils.amounts import extract_amounts_from_line
from ..utils.dates import parse_french_date
# Tolérance verticale (points PDF) pour regrouper les mots d'une même ligne visuelle.
_Y_TOL = 3.0
_LOT_RE = re.compile(
r"^Lot\s+(\d{4})\s+"
r"(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)"
)
_PERIODE_RE = re.compile(r"Du\s+\d{2}\.\d{2}\.\d{2}\s+Au\s+(\d{2}\.\d{2}\.\d{2})")
# En-tête de colonne (normalisé sans accents) -> clé canonique.
_HEADER_MAP = {
"locataires": "loc",
"periode": "periode",
"loyers": "loyers",
"taxes": "taxes",
"provisions": "provisions",
"divers": "divlbl", # libellé divers
"total": "total",
"regles": "regles",
"impayes": "impayes",
}
def _strip_accents(text: str) -> str:
return "".join(c for c in _normalize("NFD", text) if ord(c) < 128).lower()
def _num(cell: str) -> float:
"""Dernier montant d'une cellule (les dates DD.MM.YY sont exclues)."""
amounts = extract_amounts_from_line(cell or "")
return amounts[-1] if amounts else 0.0
def _column_keys(header_cells, page) -> list[str | None]:
"""Associe chaque colonne du tableau à une clé canonique via son en-tête.
La colonne sans en-tête qui suit « Divers » porte le **montant** divers
(le libellé étant dans la colonne « Divers »).
"""
keys: list[str | None] = []
prev: str | None = None
for cell in header_cells:
label = ""
if cell is not None:
label = _strip_accents((page.crop(cell).extract_text() or "").strip())
key = _HEADER_MAP.get(label)
if key is None:
key = "divamt" if prev == "divlbl" else None
keys.append(key)
if key is not None:
prev = key
return keys
def _rows_from_page(page) -> list[dict]:
"""Retourne les lignes visuelles du tableau « situation locataires » d'une page.
Chaque ligne est un ``dict`` {clé_colonne: texte}. Retourne ``[]`` si la page
ne contient pas ce tableau.
"""
# Repérer le tableau « situation locataires » (en-tête commençant par « Locataires »).
table = None
for candidate in page.find_tables():
header = candidate.rows[0].cells
if header and header[0] is not None:
first = _strip_accents(page.crop(header[0]).extract_text() or "")
if "locataires" in first:
table = candidate
break
if table is None:
return []
header = table.rows[0].cells
keys = _column_keys(header, page)
bands = [(c[0], c[2]) if c is not None else None for c in header]
def column_of(x_center: float) -> int | None:
for i, band in enumerate(bands):
if band and band[0] - 1 <= x_center <= band[1] + 1:
return i
return None
words = page.crop(table.bbox).extract_words()
words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"]))
# Regrouper les mots en lignes visuelles (clustering vertical tolérant).
clusters: list[list] = []
for word in words:
y_center = (word["top"] + word["bottom"]) / 2
if clusters and abs(y_center - clusters[-1][0]) <= _Y_TOL:
clusters[-1][1].append(word)
else:
clusters.append([y_center, [word]])
rows: list[dict] = []
for _y, line_words in clusters:
cells: dict[str, list[str]] = {}
for word in sorted(line_words, key=lambda w: w["x0"]):
idx = column_of((word["x0"] + word["x1"]) / 2)
key = keys[idx] if idx is not None else None
if key is not None:
cells.setdefault(key, []).append(word["text"])
rows.append({k: " ".join(v) for k, v in cells.items()})
return rows
def _new_lot(numero: str, lot_type: str) -> dict:
return {
"lot": {"numero": numero, "type": lot_type},
"locataire": {"nom": ""},
"lignes": [],
"totaux": {
"solde_anterieur": 0.0,
"loyers": 0.0,
"taxes": 0.0,
"provisions": 0.0,
"divers": 0.0,
"total": 0.0,
"regles": 0.0,
"impayes": 0.0,
},
}
def _periode(row: dict) -> dict:
if not _PERIODE_RE.search(row.get("periode", "")):
return {"debut": None, "fin": None}
dates = re.findall(r"\d{2}\.\d{2}\.\d{2}", row.get("periode", ""))
return {
"debut": parse_french_date(dates[0]) if dates else None,
"fin": parse_french_date(dates[1]) if len(dates) > 1 else None,
}
def _append_periode_line(lot: dict, row: dict) -> None:
"""Ajoute une ligne loyer ou divers à partir d'une ligne de période."""
loyers = _num(row.get("loyers", ""))
taxes = _num(row.get("taxes", ""))
provisions = _num(row.get("provisions", ""))
divers_montant = _num(row.get("divamt", ""))
divers_libelle = row.get("divlbl") or None
total = _num(row.get("total", ""))
regles = _num(row.get("regles", ""))
impayes = _num(row.get("impayes", ""))
# Ligne purement « divers » : montant divers présent, colonnes loyer vides.
is_divers = (
divers_montant != 0.0 and loyers == 0.0 and taxes == 0.0 and provisions == 0.0
)
if is_divers:
lot["lignes"].append(
{
"type": "divers",
"periode": _periode(row),
"loyers": 0.0,
"taxes": 0.0,
"provisions": 0.0,
"divers": {"montant": divers_montant, "libelle": divers_libelle},
"total": total,
"regles": regles,
"impayes": impayes,
}
)
else:
lot["lignes"].append(
{
"type": "loyer",
"periode": _periode(row),
"loyers": loyers,
"taxes": taxes,
"provisions": provisions,
"divers": (
{"montant": divers_montant, "libelle": divers_libelle}
if divers_montant
else {"montant": 0.0, "libelle": None}
),
"total": total,
"regles": regles,
"impayes": impayes,
}
)
def _fill_totaux(lot: dict, row: dict) -> None:
totaux = lot["totaux"]
# Un solde antérieur reporté apparaît dans la colonne « période » de la ligne Totaux.
solde_totaux = _num(row.get("periode", ""))
if solde_totaux and not totaux["solde_anterieur"]:
totaux["solde_anterieur"] = solde_totaux
totaux["loyers"] = _num(row.get("loyers", ""))
totaux["taxes"] = _num(row.get("taxes", ""))
totaux["provisions"] = _num(row.get("provisions", ""))
totaux["divers"] = _num(row.get("divamt", ""))
totaux["total"] = _num(row.get("total", ""))
totaux["regles"] = _num(row.get("regles", ""))
totaux["impayes"] = _num(row.get("impayes", ""))
def extract_situation_locataires_from_pdf(pdf_path: str) -> list[dict]:
"""Extrait la situation des locataires par cellules de tableau.
Args:
pdf_path: Chemin vers le PDF de compte rendu de gérance.
Returns:
Liste des situations par lot (même structure que
:func:`plesna_gerance.parsers.locataires.extract_situation_locataires`).
"""
situations: list[dict] = []
current: dict | None = None
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
if "SITUATION DES LOCATAIRES" not in (page.extract_text() or ""):
continue
for row in _rows_from_page(page):
loc = (row.get("loc") or "").strip()
periode = (row.get("periode") or "").strip()
# En-tête de colonnes réimprimé.
if _strip_accents(loc) == "locataires":
continue
# Nouveau lot.
lot_match = _LOT_RE.match(loc)
if lot_match:
if current:
situations.append(current)
current = _new_lot(lot_match.group(1), lot_match.group(2))
# La ligne d'en-tête de lot peut porter un premier loyer.
if _PERIODE_RE.search(periode):
_append_periode_line(current, row)
continue
if current is None:
continue
# Ligne Totaux du lot.
if loc.startswith("Totaux"):
_fill_totaux(current, row)
continue
# Solde Antérieur (libellé + montant dans la colonne période).
if periode.startswith("Solde Antérieur"):
montant = _num(periode)
current["lignes"].append(
{
"type": "solde_anterieur",
"periode": {"debut": None, "fin": None},
"loyers": montant,
"taxes": 0.0,
"provisions": 0.0,
"divers": {"montant": 0.0, "libelle": None},
"total": _num(row.get("total", "")) or montant,
"regles": _num(row.get("regles", "")),
"impayes": _num(row.get("impayes", "")),
}
)
current["totaux"]["solde_anterieur"] = montant
continue
# Rappel de loyer.
if loc.startswith("Rappel"):
current["lignes"].append(
{
"type": "rappel_loyer",
"periode": _periode(row),
"loyers": _num(row.get("loyers", "")),
"taxes": 0.0,
"provisions": 0.0,
"divers": {"montant": 0.0, "libelle": None},
"total": _num(row.get("total", "")),
"regles": _num(row.get("regles", "")),
"impayes": _num(row.get("impayes", "")),
}
)
continue
# Ligne de période (loyer ou divers).
if _PERIODE_RE.search(periode):
_append_periode_line(current, row)
continue
# Nom du locataire (cellule « Locataires » seule, nom pas encore trouvé).
if loc and not current["locataire"]["nom"]:
current["locataire"]["nom"] = loc
if current:
situations.append(current)
return situations

View File

@@ -2,15 +2,40 @@
import re
from ..utils.dates import parse_french_date
from ..utils.amounts import parse_amount
from ..utils.dates import parse_french_date
def extract_metadata(text: str) -> dict:
def _reference_from_words(words: list[dict]) -> str | None:
"""Récupère le numéro de référence via les coordonnées des mots.
Le numéro figure sur la même ligne que le libellé « REFERENCES », à sa
droite. L'alignement par espaces n'étant pas fiable sur cet en-tête
multi-colonnes, on s'appuie sur la position géométrique des mots.
"""
labels = [w for w in words if "REFERENCE" in w["text"].upper()]
for label in labels:
same_line = sorted(
(
w
for w in words
if abs(w["top"] - label["top"]) < 5 and w["x0"] > label["x1"]
),
key=lambda w: w["x0"],
)
for w in same_line:
if re.fullmatch(r"\d{4,}", w["text"]):
return w["text"]
return None
def extract_metadata(text: str, words: list[dict] | None = None) -> dict:
"""Extrait les métadonnées du document.
Args:
text: Texte complet du PDF
text: Texte complet du PDF (mise en page préservée)
words: Mots de la première page avec coordonnées (optionnel).
Utilisé pour fiabiliser les champs d'en-tête multi-colonnes.
Returns:
Dictionnaire contenant:
@@ -48,8 +73,10 @@ def extract_metadata(text: str) -> dict:
editeur["capital"] = m.group(1).replace(" ", "")
# Destinataire
# On exige la forme pointée « S.C.I. » : pdfplumber peut produire un
# « SCI » parasite (sans points) dans l'en-tête, qu'il faut ignorer.
destinataire = {"nom": "", "adresse": ""}
m = re.search(r"(S\.?C\.?I\.?\s+\w+)", text)
m = re.search(r"(S\.C\.I\.\s+[A-Za-zÉÈ][\w\-]*)", text)
if m:
destinataire["nom"] = m.group(1).strip()
@@ -65,9 +92,15 @@ def extract_metadata(text: str) -> dict:
# Document
document = {"reference": "", "date": "", "type": "COMPTE RENDU DE GESTION"}
m = re.search(r"REFERENCES\s+(\d+)", text)
if m:
document["reference"] = m.group(1)
# En priorité via les coordonnées (fiable sur l'en-tête multi-colonnes),
# puis repli sur le texte si les mots ne sont pas fournis.
ref = _reference_from_words(words) if words else None
if ref is None:
m = re.search(r"REFERENCES\s+(\d+)", text)
if m:
ref = m.group(1)
if ref:
document["reference"] = ref
m = re.search(r"Lyon le (\d{2}/\d{2}/\d{4})", text)
if m:

View File

@@ -26,8 +26,10 @@ def _extract_lot_code_from_description(description: str) -> str | None:
if not description:
return None
# Pattern: lettre majuscule + 1-2 chiffres, suivi de " - " ou fin de mot
match = re.search(r"\b[A-Z](\d{1,2})\s*-", description)
# Pattern: lettre majuscule + (espace optionnelle) + 1-2 chiffres, terminé par
# un espace, un tiret ou la fin. Gère "S10 - ...", "S 17 - ..." (espace dans le
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
if match:
lot_num = match.group(1)
# Formater sur 4 chiffres (ex: "6" -> "0006", "12" -> "0012")
@@ -175,6 +177,9 @@ def extract_recapitulatif_operations(text: str) -> list[dict]:
if current_fournisseur and description.startswith(current_fournisseur):
description = description[len(current_fournisseur) :].strip()
# Réduire les espaces multiples (séparateurs de colonnes) en un seul
description = re.sub(r"\s{2,}", " ", description).strip()
if description and not description.startswith("Totaux"):
# Extraire le numéro de lot depuis la description (ex: M06 -> 0006)
lot_numero = _extract_lot_code_from_description(description)

View File

@@ -0,0 +1,242 @@
"""Extraction du récapitulatif des opérations par cellules de tableau (géométrique).
Même principe que :mod:`plesna_gerance.parsers.locataires_table` : on reconstruit
chaque **ligne visuelle** (regroupement des mots par ``y``) et on affecte chaque
valeur à **sa colonne** via les filets du tableau (bandes ``x``).
Gains sur l'ancien parseur texte (:mod:`plesna_gerance.parsers.operations`) :
- le **fournisseur** (colonne de gauche, en MAJUSCULES) est proprement séparé de la
**description** (colonne du milieu) — plus de report erroné (ex. TOTALENERGIES
étiqueté DIDIER NETTOYAGE) ;
- chaque **montant** tombe dans sa colonne (Débit / Crédit / TVA / Locatif /
Déductible), sans décalage dû aux cellules vides.
La sortie est identique en structure à ``extract_recapitulatif_operations``.
"""
from unicodedata import normalize as _normalize
import pdfplumber
from ..utils.amounts import extract_amounts_from_line
from .operations import _extract_lot_code_from_description
_Y_TOL = 3.0
# Libellé PDF (début de cellule) -> catégorie normalisée.
_CAT_KEYWORDS = {
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
"DEPENSES DEDUCTIBLES": "DEPENSES_DEDUCTIBLES",
"DEPENSES NON RECUPERABLES": "DEPENSES_NON_RECUPERABLES",
"DEPENSES RECUPERABLES PAR LOT": "DEPENSES_RECUPERABLES",
"HONORAIRES DE GESTION": "HONORAIRES_DE_GESTION",
"DIVERS": "DIVERS",
}
_AMOUNT_KEYS = ("debit", "credit", "tva", "locatif", "deductible")
def _strip_accents(text: str) -> str:
return "".join(c for c in _normalize("NFD", text) if ord(c) < 128).lower()
def _num(cell: str) -> float | None:
"""Montant d'une cellule, ou ``None`` si la cellule ne contient pas de montant."""
amounts = extract_amounts_from_line(cell or "")
return amounts[-1] if amounts else None
def _match_category(text: str) -> str | None:
up = (text or "").upper().strip()
for keyword, normalized in _CAT_KEYWORDS.items():
if up.startswith(keyword):
return normalized
return None
def _is_fournisseur(text: str) -> bool:
"""Une cellule de gauche est un fournisseur si elle est en MAJUSCULES.
Distingue « BOUVARD ENTREPRISE » (fournisseur) de « Travaux divers »
(sous-catégorie en casse mixte).
"""
letters = [c for c in text if c.isalpha()]
return bool(letters) and all(c.isupper() for c in letters) and not _match_category(text)
def _looks_like_continuation(text: str) -> bool:
"""Fragment de description débordé sur la ligne suivante (ex. « Y », « IN »)."""
return len(text) <= 4 and " " not in text and text.isalpha()
def _column_keys(header_cells, page) -> list[str | None]:
keys: list[str | None] = []
for cell in header_cells:
label = ""
if cell is not None:
label = _strip_accents((page.crop(cell).extract_text() or "").strip())
if "debit" in label:
key = "debit"
elif "credit" in label:
key = "credit"
elif "t.v.a" in label or "tva" in label:
key = "tva"
elif "locatif" in label:
key = "locatif"
elif "deductible" in label:
key = "deductible"
elif "recapitulatif" in label:
key = "desc"
elif label == "":
key = "left"
else:
key = None
keys.append(key)
return keys
def _rows_from_page(page) -> list[dict]:
"""Lignes visuelles du tableau « récapitulatif des opérations » d'une page."""
table = None
for candidate in page.find_tables():
header = " ".join(
(page.crop(c).extract_text() or "") if c is not None else ""
for c in candidate.rows[0].cells
)
if "Locatif" in header and "ductible" in header:
table = candidate
break
if table is None:
return []
header = table.rows[0].cells
keys = _column_keys(header, page)
bands = [(c[0], c[2]) if c is not None else None for c in header]
def column_of(x_center: float) -> int | None:
for i, band in enumerate(bands):
if band and band[0] - 1 <= x_center <= band[1] + 1:
return i
return None
words = page.crop(table.bbox).extract_words()
words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"]))
clusters: list[list] = []
for word in words:
y_center = (word["top"] + word["bottom"]) / 2
if clusters and abs(y_center - clusters[-1][0]) <= _Y_TOL:
clusters[-1][1].append(word)
else:
clusters.append([y_center, [word]])
rows: list[dict] = []
for _y, line_words in clusters:
cells: dict[str, list[str]] = {}
for word in sorted(line_words, key=lambda w: w["x0"]):
idx = column_of((word["x0"] + word["x1"]) / 2)
key = keys[idx] if idx is not None else None
if key is not None:
cells.setdefault(key, []).append(word["text"])
rows.append({k: " ".join(v) for k, v in cells.items()})
return rows
def extract_recapitulatif_operations_from_pdf(pdf_path: str) -> list[dict]:
"""Extrait le récapitulatif des opérations par cellules de tableau.
Returns:
Liste plate des opérations (même structure que
:func:`plesna_gerance.parsers.operations.extract_recapitulatif_operations`).
"""
operations: list[dict] = []
current_cat: str | None = None
current_fournisseur: str | None = None
current_sous_cat: str | None = None
block_id = 0
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
if "RECAPITULATIF DES OPERATIONS" not in (page.extract_text() or ""):
continue
for row in _rows_from_page(page):
left = (row.get("left") or "").strip()
desc = (row.get("desc") or "").strip()
montants = {k: _num(row.get(k, "")) for k in _AMOUNT_KEYS}
has_amount = any(v is not None for v in montants.values())
low = _strip_accents(desc)
# En-tête, totaux, solde : ignorés.
if low.startswith("recapitulatif"):
continue
if (
low.startswith("totaux")
or low.startswith("total des reglements")
or "solde crediteur" in low
):
continue
# En-tête de catégorie (sans montant).
cat = _match_category(left) or _match_category(desc)
if cat and not has_amount:
current_cat = cat
current_fournisseur = None
current_sous_cat = None
block_id += 1
continue
# Colonne de gauche : fournisseur (MAJUSCULES) ou sous-catégorie.
if left:
if _is_fournisseur(left):
current_fournisseur = left
else:
current_sous_cat = left
# Ligne sans montant : sous-catégorie (col1) ou continuation de description.
if not has_amount:
if desc:
if (
_looks_like_continuation(desc)
and operations
and operations[-1]["_block"] == block_id
):
operations[-1]["description"] = (
operations[-1]["description"] + desc
).strip()
else:
current_sous_cat = desc
continue
# Ligne avec montant : une opération.
operations.append(
{
"categorie": current_cat,
"sous_categorie": current_sous_cat or "",
"fournisseur": current_fournisseur,
"description": desc,
"lot_concerne": None,
"lot_numero": _extract_lot_code_from_description(desc),
"montants": {k: (montants[k] or 0.0) for k in _AMOUNT_KEYS},
"_block": block_id,
}
)
# Report du fournisseur unique d'un bloc sur les opérations qui en manquent
# (cas des honoraires : le nom du gestionnaire n'apparaît qu'une fois, au milieu).
by_block: dict[int, list[dict]] = {}
for op in operations:
by_block.setdefault(op["_block"], []).append(op)
for block_ops in by_block.values():
first = next((o["fournisseur"] for o in block_ops if o["fournisseur"]), None)
if first:
for op in block_ops:
if not op["fournisseur"]:
op["fournisseur"] = first
for op in operations:
op.pop("_block", None)
return operations

View File

@@ -1,28 +1,74 @@
"""Extraction de texte depuis les PDFs."""
"""Extraction de texte et de mots depuis les PDFs via pdfplumber.
import subprocess
pdfplumber est une bibliothèque Python pure : aucune dépendance système
(contrairement à l'ancien `pdftotext`/poppler), ce qui permet d'empaqueter
l'application en exécutable autonome (Windows notamment).
"""
from dataclasses import dataclass, field
import pdfplumber
@dataclass
class PdfContent:
"""Contenu extrait d'un PDF.
Attributes:
text: Texte de toutes les pages avec mise en page préservée
(équivalent de `pdftotext -layout`). Consommé par les parseurs
opérations et locataires.
words: Mots de la première page avec leurs coordonnées
(``text``, ``x0``, ``x1``, ``top``, ``bottom``). Utilisé par le
parseur de métadonnées pour les champs d'en-tête multi-colonnes
où l'alignement par espaces n'est pas fiable.
"""
text: str
words: list[dict] = field(default_factory=list)
def read_pdf(pdf_path: str) -> PdfContent:
"""Lit un PDF et retourne son texte (layout) et les mots de l'en-tête.
Args:
pdf_path: Chemin vers le fichier PDF
Returns:
Un objet :class:`PdfContent`.
"""
text_parts: list[str] = []
header_words: list[dict] = []
with pdfplumber.open(pdf_path) as pdf:
for page_index, page in enumerate(pdf.pages):
text_parts.append(page.extract_text(layout=True) or "")
# Les métadonnées se trouvent uniquement sur la première page.
if page_index == 0:
header_words = [
{
"text": w["text"],
"x0": w["x0"],
"x1": w["x1"],
"top": w["top"],
"bottom": w["bottom"],
}
for w in page.extract_words()
]
return PdfContent(text="\n".join(text_parts), words=header_words)
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extrait le texte du PDF via pdftotext.
"""Extrait le texte du PDF avec mise en page préservée.
Nécessite pdftotext (poppler-utils) installé sur le système:
- Ubuntu/Debian: sudo apt-get install poppler-utils
- macOS: brew install poppler
Conserve la signature historique (retourne une chaîne) pour la CLI et
les parseurs qui ne consomment que le texte.
Args:
pdf_path: Chemin vers le fichier PDF
Returns:
Texte extrait du PDF avec mise en page préservée
Raises:
subprocess.CalledProcessError: Si pdftotext échoue
"""
result = subprocess.run(
["pdftotext", "-layout", pdf_path, "-"],
capture_output=True,
text=True,
check=True,
)
return result.stdout
return read_pdf(pdf_path).text

View File

@@ -0,0 +1,70 @@
"""Résolution centralisée des chemins (données utilisateur et ressources).
Gère trois contextes d'exécution :
- **Développement** : lancé depuis les sources. Les données vivent dans
``<racine_projet>/data`` et les ressources (frontend buildé) dans
``<racine_projet>/frontend/dist``.
- **Exécutable empaqueté** (PyInstaller, ex. ``.exe`` Windows) : les données
doivent aller dans un dossier utilisateur inscriptible (``%APPDATA%`` sous
Windows) — l'exécutable lui-même est souvent en lecture seule
(``Program Files``). Les ressources sont extraites dans ``sys._MEIPASS``.
- **Surcharge explicite** via variables d'environnement (tests, Docker).
"""
import os
import sys
from pathlib import Path
APP_NAME = "PlesnaGerance"
def is_frozen() -> bool:
"""Vrai si on tourne depuis un exécutable PyInstaller."""
return getattr(sys, "frozen", False)
def get_bundle_dir() -> Path:
"""Répertoire racine des ressources embarquées (lecture seule).
- Empaqueté : ``sys._MEIPASS`` (dossier d'extraction PyInstaller), avec
repli sur le dossier de l'exécutable.
- Développement : racine du projet (``src/plesna_gerance/paths.py`` -> 3 crans).
"""
if is_frozen():
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return Path(meipass)
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent.parent
def resource_path(*parts: str) -> Path:
"""Chemin d'une ressource embarquée (ex. ``resource_path('frontend', 'dist')``)."""
return get_bundle_dir().joinpath(*parts)
def get_data_dir() -> Path:
"""Répertoire inscriptible des données utilisateur (DB, documents).
Priorité :
1. ``PLESNA_DATA_DIR`` si défini ;
2. dossier applicatif utilisateur si empaqueté ;
3. ``<racine_projet>/data`` en développement (comportement historique).
"""
env = os.environ.get("PLESNA_DATA_DIR")
if env:
return Path(env)
if is_frozen():
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA") or Path.home())
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(
os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")
)
return base / APP_NAME
return get_bundle_dir() / "data"

View File

@@ -2,10 +2,9 @@
from sqlalchemy.exc import IntegrityError
from ..database import init_db, get_session
from ..database import get_session, init_db
from ..database.models import Tag
# Liste des tags prédéfinis
PREDEFINED_TAGS = [
"Ascenseur",
@@ -57,7 +56,7 @@ def seed_tags():
session.commit()
print(f"\n📊 Résumé:")
print("\n📊 Résumé:")
print(f" - {created_count} tags créés")
print(f" - {skipped_count} tags existants")
print(f" - Total: {len(PREDEFINED_TAGS)} tags")

View File

@@ -5,9 +5,9 @@ import logging
import httpx
from .sql_executor import execute_readonly_sql, get_schema_description
from .settings_service import get_setting
from ..database.connection import get_session_factory
from .settings_service import get_setting
from .sql_executor import execute_readonly_sql, get_schema_description
logger = logging.getLogger(__name__)
@@ -148,7 +148,7 @@ def _execute_tool(name: str, arguments: dict) -> str:
try:
result = execute_readonly_sql(query)
return json.dumps(result, ensure_ascii=False, default=str)
except (ValueError, Exception) as e:
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
else:
return json.dumps({"error": f"Outil inconnu : {name}"})

View File

@@ -1,49 +1,72 @@
"""Exécution SQL read-only sécurisée pour l'assistant IA."""
"""Exécution SQL read-only sécurisée pour l'assistant IA.
Trois niveaux de défense, du plus fort au plus faible :
1. Connexion SQLite ouverte en ``mode=ro`` — garantie réelle au niveau du fichier.
2. Autorisation SQLite (``set_authorizer``) qui refuse toute opération non
read-only (writes, DDL, ATTACH…) au niveau du moteur, sans faux positifs.
3. Garde lexicale légère : une seule instruction, débutant par SELECT/WITH/PRAGMA,
uniquement pour produire des messages d'erreur clairs en amont.
"""
import re
import sqlite3
from ..database.connection import get_db_path
# Requêtes interdites (défense en profondeur)
_FORBIDDEN_PATTERN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|ATTACH|DETACH|REPLACE|GRANT|REVOKE)\b",
re.IGNORECASE,
)
# PRAGMA autorisés
# PRAGMA autorisés (lecture de métadonnées uniquement)
_ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"}
# Limite de résultats par défaut
MAX_ROWS = 500
# Codes d'action de l'autorisation SQLite (stables dans l'ABI SQLite ; définis
# en dur car les constantes sqlite3.SQLITE_* ne sont disponibles qu'à partir de
# Python 3.11 alors que le projet cible >= 3.10).
_SQLITE_OK = 0
_SQLITE_DENY = 1
_SQLITE_READ = 20
_SQLITE_SELECT = 21
_SQLITE_PRAGMA = 19
_SQLITE_FUNCTION = 31
_SQLITE_RECURSIVE = 33
_READONLY_ACTIONS = {_SQLITE_READ, _SQLITE_SELECT, _SQLITE_FUNCTION, _SQLITE_RECURSIVE}
def _authorizer(action: int, arg1, arg2, db_name, trigger) -> int:
"""Callback d'autorisation SQLite : n'autorise que les opérations de lecture."""
if action in _READONLY_ACTIONS:
return _SQLITE_OK
if action == _SQLITE_PRAGMA:
# arg1 = nom du PRAGMA
if arg1 and arg1.lower() in _ALLOWED_PRAGMAS:
return _SQLITE_OK
return _SQLITE_DENY
return _SQLITE_DENY
def _validate_sql(query: str) -> None:
"""Valide qu'une requête SQL est read-only.
"""Garde lexicale légère pour messages d'erreur clairs (pas la garde principale).
Raises:
ValueError: si la requête n'est pas autorisée.
ValueError: si la requête n'est manifestement pas une lecture.
"""
stripped = query.strip().rstrip(";").strip()
# Refuser les instructions multiples (stacked queries)
if ";" in stripped:
raise ValueError("Une seule instruction SQL est autorisée")
upper = stripped.upper()
# Autoriser les PRAGMA spécifiques
if upper.startswith("PRAGMA"):
pragma_name = stripped.split("(")[0].split()[-1].lower().strip()
if pragma_name not in _ALLOWED_PRAGMAS:
raise ValueError(f"PRAGMA '{pragma_name}' non autorisé")
return
# La requête doit commencer par SELECT ou WITH
if not (upper.startswith("SELECT") or upper.startswith("WITH")):
raise ValueError("Seules les requêtes SELECT ou WITH sont autorisées")
# Vérifier l'absence de mots-clés dangereux
match = _FORBIDDEN_PATTERN.search(stripped)
if match:
raise ValueError(f"Mot-clé SQL interdit détecté : {match.group()}")
def _ensure_limit(query: str) -> str:
"""Ajoute LIMIT si absent."""
@@ -64,7 +87,7 @@ def execute_readonly_sql(query: str) -> dict:
Raises:
ValueError: si la requête n'est pas autorisée.
sqlite3.Error: si l'exécution échoue.
sqlite3.Error: si l'exécution échoue (y compris refus de l'autorisation).
"""
_validate_sql(query)
query = _ensure_limit(query)
@@ -73,6 +96,7 @@ def execute_readonly_sql(query: str) -> dict:
uri = f"file:{db_path}?mode=ro"
conn = sqlite3.connect(uri, uri=True)
try:
conn.set_authorizer(_authorizer)
cursor = conn.execute(query)
columns = [desc[0] for desc in cursor.description] if cursor.description else []
rows = cursor.fetchall()

View File

@@ -1,9 +1,7 @@
"""Service de prédiction de tags pour les dépenses basé sur l'historique."""
from typing import Optional
from collections import Counter
from sqlalchemy import select, func
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ..database.models import Depense, Tag
@@ -14,8 +12,8 @@ class TagPrediction:
def __init__(
self,
tag_id: Optional[int],
tag_name: Optional[str],
tag_id: int | None,
tag_name: str | None,
confidence: float,
reason: str,
):
@@ -72,7 +70,7 @@ class TagPredictor:
reason="Aucun historique trouvé",
)
def _predict_by_fournisseur(self, fournisseur: str) -> Optional[TagPrediction]:
def _predict_by_fournisseur(self, fournisseur: str) -> TagPrediction | None:
"""Prédit le tag basé sur le fournisseur.
Retourne le tag le plus fréquemment utilisé pour ce fournisseur.
@@ -111,7 +109,7 @@ class TagPredictor:
def _predict_by_sous_categorie(
self, sous_categorie: str
) -> Optional[TagPrediction]:
) -> TagPrediction | None:
"""Prédit le tag basé sur la sous-catégorie.
Retourne le tag le plus fréquemment utilisé pour cette sous-catégorie.

View File

@@ -1,6 +1,6 @@
"""Utilitaires pour le parsing des données de gérance."""
from .amounts import extract_amounts_from_line, parse_amount
from .dates import parse_french_date
from .amounts import parse_amount, extract_amounts_from_line
__all__ = ["parse_french_date", "parse_amount", "extract_amounts_from_line"]

View File

@@ -0,0 +1,48 @@
"""Lecture bornée des fichiers uploadés (protection mémoire / DoS)."""
from fastapi import UploadFile
# Taille maximale acceptée pour un PDF uploadé (20 Mo)
MAX_UPLOAD_SIZE = 20 * 1024 * 1024
_CHUNK_SIZE = 1024 * 1024 # 1 Mo
class UploadTooLargeError(Exception):
"""Levée quand un fichier uploadé dépasse la taille maximale autorisée."""
def __init__(self, max_size: int):
self.max_size = max_size
super().__init__(
f"Fichier trop volumineux (maximum {max_size // (1024 * 1024)} Mo)"
)
async def read_upload_limited(
file: UploadFile, max_size: int = MAX_UPLOAD_SIZE
) -> bytes:
"""Lit un fichier uploadé par morceaux en plafonnant la taille totale.
Évite de charger en mémoire un fichier arbitrairement gros.
Args:
file: Fichier uploadé (FastAPI UploadFile).
max_size: Taille maximale en octets.
Returns:
Contenu binaire du fichier.
Raises:
UploadTooLargeError: si le fichier dépasse ``max_size``.
"""
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(_CHUNK_SIZE)
if not chunk:
break
total += len(chunk)
if total > max_size:
raise UploadTooLargeError(max_size)
chunks.append(chunk)
return b"".join(chunks)

79
tests/conftest.py Normal file
View File

@@ -0,0 +1,79 @@
"""Fixtures partagées pour les tests."""
import pytest
from plesna_gerance.database import connection
@pytest.fixture
def db_session(tmp_path, monkeypatch):
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
Configure aussi PLESNA_DB_PATH / PLESNA_STORAGE_PATH pour que les helpers
qui lisent l'environnement (sql_executor, storage) ciblent le temp dir.
"""
db_path = tmp_path / "test.sqlite"
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
connection.reset_connection()
connection.init_db(db_path)
SessionLocal = connection.get_session_factory()
session = SessionLocal()
try:
yield session
finally:
session.close()
connection.reset_connection()
@pytest.fixture
def sample_data():
"""Données extraites minimales mais complètes pour save_document."""
return {
"metadata": {
"document": {
"reference": "REF001",
"date": "2024-01-15",
"type": "COMPTE RENDU DE GESTION",
},
"immeuble": {
"code": "IMM1",
"adresse": "4 RUE SERVIENT",
"ville": "LYON",
"code_postal": "69003",
},
"editeur": {"nom": "ORALIA", "siret": "12345678901234"},
"solde": {
"montant": 100.0,
"type": "crediteur",
"date_arrete": "2024-01-15",
},
},
"situation_locataires": [
{
"lot": {"numero": "001", "type": "Appartement"},
"locataire": {"nom": "DUPONT"},
"lignes": [
{
"type": "loyer",
"periode": {"debut": "2024-01-01", "fin": "2024-01-31"},
"loyers": 500.0,
"total": 500.0,
"regles": 500.0,
"impayes": 0.0,
}
],
}
],
"recapitulatif_operations": [
{
"categorie": "DEPENSES_LOCATIVES",
"sous_categorie": "Nettoyage",
"fournisseur": "ACME",
"description": "Nettoyage immeuble",
"montants": {"debit": 50.0},
}
],
}

39
tests/test_amounts.py Normal file
View File

@@ -0,0 +1,39 @@
"""Tests du parsing des montants français."""
import pytest
from plesna_gerance.utils.amounts import extract_amounts_from_line, parse_amount
@pytest.mark.parametrize(
"text,expected",
[
("123,45", 123.45),
("123.45", 123.45),
("1 234,56", 1234.56),
("1.234,56", 1234.56),
("1 234,56", 1234.56),
("123,45 €", 123.45),
("", 0.0),
("abc", 0.0),
("-45,67", -45.67),
],
)
def test_parse_amount(text, expected):
assert parse_amount(text) == pytest.approx(expected)
def test_extract_amounts_ignores_dates():
# 01.01.25 est une date, pas un montant
line = "Loyer du 01.01.25 au 31.01.25 : 500,00 réglé 500,00"
amounts = extract_amounts_from_line(line)
assert amounts == [500.00, 500.00]
def test_extract_amounts_negative_and_thousands():
line = "Solde -1 234,56 et frais 12,00"
assert extract_amounts_from_line(line) == [-1234.56, 12.00]
def test_extract_amounts_none_found():
assert extract_amounts_from_line("aucun montant ici") == []

View File

@@ -0,0 +1,126 @@
"""Tests de DatabaseService.save_document (logique métier centrale)."""
from datetime import date
import pytest
from plesna_gerance.database.models import (
Depense,
Document,
Immeuble,
Locataire,
Lot,
Revenu,
Tag,
)
from plesna_gerance.database.service import DatabaseService, DuplicateDocumentError
def test_save_document_creates_full_graph(db_session, sample_data):
service = DatabaseService(db_session)
doc = service.save_document(data=sample_data, source_file="cr.pdf")
assert doc.id is not None
assert doc.reference == "REF001"
assert doc.date == date(2024, 1, 15)
assert doc.solde_montant == 100.0
# Immeuble / lot / locataire créés
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Lot).filter_by(numero="001").count() == 1
assert db_session.query(Locataire).filter_by(nom="DUPONT").count() == 1
# Revenu et dépense rattachés
revenus = db_session.query(Revenu).all()
assert len(revenus) == 1 and revenus[0].loyers == 500.0
depenses = db_session.query(Depense).all()
assert len(depenses) == 1 and depenses[0].debit == 50.0
def test_save_document_requires_reference_and_date(db_session):
service = DatabaseService(db_session)
with pytest.raises(ValueError):
service.save_document(data={"metadata": {"document": {}}})
def test_save_document_duplicate_raises(db_session, sample_data):
service = DatabaseService(db_session)
service.save_document(data=sample_data)
with pytest.raises(DuplicateDocumentError):
service.save_document(data=sample_data)
def test_save_document_overwrite_replaces(db_session, sample_data):
service = DatabaseService(db_session)
first = service.save_document(data=sample_data)
first_id = first.id
# Réécriture : l'ancien document est supprimé puis recréé (cascade incluse).
# NB : SQLite peut réutiliser le même rowid, on ne compare donc pas les ids.
assert first_id is not None
service.save_document(data=sample_data, overwrite=True)
assert db_session.query(Document).count() == 1
assert db_session.query(Revenu).count() == 1
assert db_session.query(Depense).count() == 1
def test_save_document_reuses_immeuble(db_session, sample_data):
service = DatabaseService(db_session)
service.save_document(data=sample_data)
# Deuxième document, même immeuble, référence différente
data2 = {**sample_data}
data2["metadata"] = {
**sample_data["metadata"],
"document": {**sample_data["metadata"]["document"], "reference": "REF002"},
}
service.save_document(data=data2)
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
assert db_session.query(Document).count() == 2
def test_save_document_assigns_tags(db_session, sample_data):
service = DatabaseService(db_session)
tag = db_session.query(Tag).first()
assert tag is not None
doc = service.save_document(
data=sample_data,
depenses_tags=[{"index": 0, "tag_id": tag.id}],
)
depense = db_session.query(Depense).filter_by(document_id=doc.id).first()
assert depense.tag_id == tag.id
@pytest.mark.parametrize(
"raw,expected",
[
(100.0, 100.0),
(100, 100.0),
("1 234,56", 1234.56), # string française -> parsée
(None, None),
({}, None), # type inattendu -> None, jamais stocké tel quel
(True, None), # bool exclu
],
)
def test_normalize_amount(raw, expected):
assert DatabaseService._normalize_amount(raw) == expected
def test_save_document_solde_string_is_normalized(db_session, sample_data):
sample_data["metadata"]["solde"]["montant"] = "1 234,56"
service = DatabaseService(db_session)
doc = service.save_document(data=sample_data)
assert isinstance(doc.solde_montant, float)
assert doc.solde_montant == pytest.approx(1234.56)
def test_check_duplicate(db_session, sample_data):
service = DatabaseService(db_session)
assert service.check_duplicate("REF001", date(2024, 1, 15)) is None
service.save_document(data=sample_data)
found = service.check_duplicate("REF001", date(2024, 1, 15))
assert found is not None and found.reference == "REF001"

22
tests/test_dates.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests de la conversion des dates françaises en ISO."""
import pytest
from plesna_gerance.utils.dates import parse_french_date
@pytest.mark.parametrize(
"raw,expected",
[
("01.01.25", "2025-01-01"),
("31.12.99", "1999-12-31"), # < 50 -> 20xx, >= 50 -> 19xx
("15.06.49", "2049-06-15"),
("15.06.50", "1950-06-15"),
("01/01/2025", "2025-01-01"),
("01.01.2025", "2025-01-01"),
("", ""),
("pas une date", "pas une date"), # format inconnu -> renvoyé tel quel
],
)
def test_parse_french_date(raw, expected):
assert parse_french_date(raw) == expected

View File

@@ -0,0 +1,93 @@
"""Tests de l'exécution SQL read-only de l'assistant IA."""
import sqlite3
import pytest
from plesna_gerance.services.sql_executor import (
_SQLITE_DENY,
_SQLITE_OK,
_SQLITE_PRAGMA,
_SQLITE_READ,
_SQLITE_SELECT,
MAX_ROWS,
_authorizer,
_ensure_limit,
_validate_sql,
execute_readonly_sql,
)
# --- Garde lexicale -------------------------------------------------------
def test_validate_rejects_non_select():
with pytest.raises(ValueError):
_validate_sql("DELETE FROM tags")
def test_validate_rejects_stacked_statements():
with pytest.raises(ValueError):
_validate_sql("SELECT 1; DROP TABLE tags")
def test_validate_rejects_unknown_pragma():
with pytest.raises(ValueError):
_validate_sql("PRAGMA writable_schema = ON")
def test_validate_allows_select_and_with():
_validate_sql("SELECT * FROM tags")
_validate_sql("WITH t AS (SELECT 1) SELECT * FROM t")
_validate_sql("PRAGMA table_info(tags)")
def test_ensure_limit_adds_limit():
assert _ensure_limit("SELECT * FROM tags").endswith(f"LIMIT {MAX_ROWS}")
def test_ensure_limit_preserves_existing():
q = "SELECT * FROM tags LIMIT 5"
assert _ensure_limit(q) == q
# --- Autorisation SQLite (unitaire) ---------------------------------------
def test_authorizer_allows_reads():
assert _authorizer(_SQLITE_SELECT, None, None, None, None) == _SQLITE_OK
assert _authorizer(_SQLITE_READ, "tags", "nom", "main", None) == _SQLITE_OK
def test_authorizer_allows_whitelisted_pragma():
assert _authorizer(_SQLITE_PRAGMA, "table_info", "tags", None, None) == _SQLITE_OK
def test_authorizer_denies_unknown_pragma():
assert _authorizer(_SQLITE_PRAGMA, "writable_schema", "ON", None, None) == _SQLITE_DENY
def test_authorizer_denies_unknown_action():
# 9 = SQLITE_DELETE, doit être refusé
assert _authorizer(9, "tags", None, "main", None) == _SQLITE_DENY
# --- Exécution réelle (nécessite une base) --------------------------------
def test_execute_select_returns_rows(db_session):
# init_db seed des tags prédéfinis
result = execute_readonly_sql("SELECT nom FROM tags ORDER BY nom")
assert "nom" in result["columns"]
assert result["row_count"] >= 1
def test_execute_write_blocked(db_session):
# Bloqué par la garde lexicale (ValueError) ou, à défaut, par mode=ro /
# l'autorisation au niveau SQLite (DatabaseError). Dans tous les cas : refusé.
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("DELETE FROM tags")
def test_execute_attach_blocked(db_session):
with pytest.raises((ValueError, sqlite3.DatabaseError)):
execute_readonly_sql("ATTACH DATABASE 'x.db' AS x")

50
tests/test_storage.py Normal file
View File

@@ -0,0 +1,50 @@
"""Tests des helpers de stockage (chemins, sanitisation)."""
from datetime import date
import pytest
from plesna_gerance.database.storage import (
compute_document_paths,
extract_street_letter,
sanitize_filename,
)
@pytest.mark.parametrize(
"adresse,expected",
[
("4 RUE SERVIENT", "S"),
("33 RUE MARC BLOCH", "M"),
("12 AVENUE JEAN JAURES", "J"),
("1 BOULEVARD GAMBETTA", "G"),
(None, "X"),
("", "X"),
("42", "X"), # que des chiffres -> rien d'alpha
],
)
def test_extract_street_letter(adresse, expected):
assert extract_street_letter(adresse) == expected
def test_sanitize_filename_removes_problematic_chars():
assert sanitize_filename('a/b:c*d?"e') == "a_b_c_d__e"
def test_sanitize_filename_strips_dots_and_spaces():
assert sanitize_filename(" .nom. ") == "nom"
def test_compute_document_paths():
pdf, json_p = compute_document_paths(
reference="REF-01234",
doc_date=date(2024, 1, 15),
immeuble_adresse="4 RUE SERVIENT",
)
assert pdf == "2024/S_REF-01234_2024-01-15.pdf"
assert json_p == "2024/S_REF-01234_2024-01-15.json"
def test_compute_document_paths_unknown_address():
pdf, _ = compute_document_paths("R1", date(2023, 7, 9), None)
assert pdf == "2023/X_R1_2023-07-09.pdf"

29
tests/test_uploads.py Normal file
View File

@@ -0,0 +1,29 @@
"""Tests de la lecture bornée des uploads."""
import asyncio
import io
import pytest
from fastapi import UploadFile
from plesna_gerance.utils.uploads import UploadTooLargeError, read_upload_limited
def _upload(content: bytes) -> UploadFile:
return UploadFile(filename="x.pdf", file=io.BytesIO(content))
def test_read_upload_under_limit():
content = b"hello world"
result = asyncio.run(read_upload_limited(_upload(content), max_size=1024))
assert result == content
def test_read_upload_over_limit_raises():
content = b"x" * 2048
with pytest.raises(UploadTooLargeError):
asyncio.run(read_upload_limited(_upload(content), max_size=1024))
def test_read_upload_empty():
assert asyncio.run(read_upload_limited(_upload(b""), max_size=1024)) == b""

947
uv.lock generated

File diff suppressed because it is too large Load Diff