Compare commits
10 Commits
bd8b9a044c
...
b438cbf4cd
| Author | SHA1 | Date | |
|---|---|---|---|
| b438cbf4cd | |||
| 0d39ea810b | |||
| c79ecc45ff | |||
| 2d8b2ff42f | |||
| a90d1c4f64 | |||
| 7cebc0bb3d | |||
| d42bfecab4 | |||
| 4606d6785b | |||
| 635a094591 | |||
| c15b65f0d9 |
@@ -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
|
||||
|
||||
82
.gitea/workflows/docker-publish.yml
Normal file
82
.gitea/workflows/docker-publish.yml
Normal 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
55
.github/workflows/build-windows.yml
vendored
Normal 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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
74
README.md
74
README.md
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
278
frontend/package-lock.json
generated
278
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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: () => []
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
132
frontend/src/components/ReExtractionDiff.vue
Normal file
132
frontend/src/components/ReExtractionDiff.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
215
frontend/src/utils/diffExtraction.js
Normal file
215
frontend/src/utils/diffExtraction.js
Normal 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
9
packaging/app_entry.py
Normal 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()
|
||||
53
packaging/build_windows.ps1
Normal file
53
packaging/build_windows.ps1
Normal 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
45
packaging/installer.iss
Normal 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
|
||||
83
packaging/plesna_gerance.spec
Normal file
83
packaging/plesna_gerance.spec
Normal 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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Requests
|
||||
# ============================================================
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
87
src/plesna_gerance/desktop.py
Normal file
87
src/plesna_gerance/desktop.py
Normal 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()
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
322
src/plesna_gerance/parsers/locataires_table.py
Normal file
322
src/plesna_gerance/parsers/locataires_table.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
242
src/plesna_gerance/parsers/operations_table.py
Normal file
242
src/plesna_gerance/parsers/operations_table.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
70
src/plesna_gerance/paths.py
Normal file
70
src/plesna_gerance/paths.py
Normal 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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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}"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
48
src/plesna_gerance/utils/uploads.py
Normal file
48
src/plesna_gerance/utils/uploads.py
Normal 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
79
tests/conftest.py
Normal 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
39
tests/test_amounts.py
Normal 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") == []
|
||||
126
tests/test_database_service.py
Normal file
126
tests/test_database_service.py
Normal 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
22
tests/test_dates.py
Normal 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
|
||||
93
tests/test_sql_executor.py
Normal file
93
tests/test_sql_executor.py
Normal 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
50
tests/test_storage.py
Normal 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
29
tests/test_uploads.py
Normal 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""
|
||||
Reference in New Issue
Block a user