Compare commits

..

2 Commits

Author SHA1 Message Date
0e30ed8501 fix: corrige la perte de lots quand une page coupe le tableau des locataires
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 1m32s
Build and Publish Docker Image / Build Summary (push) Successful in 4s
pdfplumber fragmente parfois le tableau en plusieurs bbox disjoints quand un
filet de séparation manque entre deux lots (ex: page 3 du sample), ce qui
faisait disparaître silencieusement les lots tombés dans l'interstice ou
situés après le fragment ignoré. Corrige aussi la troncature du nom de
locataire quand il s'étale sur plusieurs lignes visuelles ou coïncide avec
la ligne de période (lot coupé entre deux pages).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 08:16:27 +02:00
8972bcaa06 deploy: consomme l'image Docker publiée par la CI au lieu d'un build local
Le docker-compose.yml tire désormais git.opytex.org/lafrite/plesna-gerance:latest
et persiste les données dans un volume nommé (plesna-data) plutôt qu'un bind-mount.
make docker fait pull+up ; make docker-build reste disponible pour tester l'image en local.
2026-07-24 18:12:43 +02:00
4 changed files with 63 additions and 21 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: dev_back dev_front dev docker .PHONY: dev_back dev_front dev docker docker-build
# Backend avec auto-reload uvicorn # Backend avec auto-reload uvicorn
dev_back: dev_back:
@@ -12,6 +12,11 @@ dev_front:
dev: dev:
$(MAKE) dev_back & $(MAKE) dev_front & wait $(MAKE) dev_back & $(MAKE) dev_front & wait
# Build et lance via podman-compose # Tire l'image publiee (registre Gitea) et lance via podman-compose
docker: docker:
podman-compose up --build podman-compose pull
podman-compose up -d
# Construit l'image en local (test avant publication)
docker-build:
podman build -f backend.Dockerfile -t plesna-gerance:local .

View File

@@ -37,19 +37,27 @@ Ouvrir **http://localhost:5173** — le proxy Vite redirige `/api` vers le backe
## Production (Docker) ## Production (Docker)
Un **conteneur unique** : l'image builde le frontend Vue puis le fait servir Un **conteneur unique** : l'image embarque le frontend Vue builde, servi par
par le backend FastAPI (API + interface web sur le meme port). Pas de nginx. le backend FastAPI (API + interface web sur le meme port). Pas de nginx.
L'image est **publiee par la CI Gitea** (`.gitea/workflows/docker-publish.yml`)
sur `git.opytex.org/lafrite/plesna-gerance`. Le `docker-compose.yml` la **tire**
directement (pas de build local) :
```bash ```bash
make docker # podman-compose up --build make docker # podman-compose pull && podman-compose up -d
# ou : docker compose up --build # ou : docker compose pull && docker compose up -d
``` ```
Accessible sur **http://localhost:8080**. Accessible sur **http://localhost:8080**.
Les donnees (base SQLite + documents) sont declarees via `PLESNA_DATA_DIR` Les donnees (base SQLite + documents) sont declarees via `PLESNA_DATA_DIR`
(voir `docker-compose.yml`) et persistees dans le volume `./data`, donc (voir `docker-compose.yml`) et persistees dans un volume nomme dedie
conservees entre les redemarrages du conteneur. (`plesna-data`), donc conservees entre les redemarrages et recreations du
conteneur.
> Build local de l'image (test avant publication) : `make docker-build`
> (`podman build -f backend.Dockerfile -t plesna-gerance:local .`).
## Application de bureau (Windows, sans ligne de commande) ## Application de bureau (Windows, sans ligne de commande)

View File

@@ -1,16 +1,18 @@
services: services:
app: app:
build: # Image publiée par la CI Gitea (pas de build local).
context: . image: git.opytex.org/lafrite/plesna-gerance:latest
dockerfile: backend.Dockerfile
ports: ports:
# hôte:conteneur — l'app (API + interface web) écoute sur 8000 # hôte:conteneur — l'app (API + interface web) écoute sur 8000
- "8080:8000" - "8080:8000"
volumes: volumes:
# Persiste base SQLite + documents hors du conteneur. # Volume nommé dédié aux données (base SQLite + documents).
- ./data:/app/data - plesna-data:/app/data
environment: environment:
# Déclare l'emplacement des données ; get_data_dir() en dérive # Le conteneur écrit ses données ici ; get_data_dir() en dérive
# database.sqlite et documents/ (cf. src/plesna_gerance/paths.py). # database.sqlite et documents/ (cf. src/plesna_gerance/paths.py).
- PLESNA_DATA_DIR=/app/data - PLESNA_DATA_DIR=/app/data
restart: unless-stopped restart: unless-stopped
volumes:
plesna-data:

View File

@@ -88,8 +88,9 @@ def _rows_from_page(page) -> list[dict]:
ne contient pas ce tableau. ne contient pas ce tableau.
""" """
# Repérer le tableau « situation locataires » (en-tête commençant par « Locataires »). # Repérer le tableau « situation locataires » (en-tête commençant par « Locataires »).
candidates = page.find_tables()
table = None table = None
for candidate in page.find_tables(): for candidate in candidates:
header = candidate.rows[0].cells header = candidate.rows[0].cells
if header and header[0] is not None: if header and header[0] is not None:
first = _strip_accents(page.crop(header[0]).extract_text() or "") first = _strip_accents(page.crop(header[0]).extract_text() or "")
@@ -109,7 +110,20 @@ def _rows_from_page(page) -> list[dict]:
return i return i
return None return None
words = page.crop(table.bbox).extract_words() # Quand un filet de séparation manque entre deux lots, pdfplumber détecte
# la table logique en plusieurs fragments dont les bbox se touchent sans
# se recouvrir : une ligne à cheval sur la coupure tombe alors hors des
# deux bbox et serait perdue. On étend donc la zone scannée jusqu'au bas
# du fragment le plus bas partageant la même marge gauche que l'ancre,
# pour balayer une zone continue plutôt que des bbox disjointes.
x0, top, x1, bottom = table.bbox
for candidate in candidates:
if candidate is table:
continue
if abs(candidate.bbox[0] - x0) <= 2:
bottom = max(bottom, candidate.bbox[3])
words = page.crop((x0, top, x1, bottom)).extract_words()
words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"])) words.sort(key=lambda w: (round((w["top"] + w["bottom"]) / 2, 1), w["x0"]))
# Regrouper les mots en lignes visuelles (clustering vertical tolérant). # Regrouper les mots en lignes visuelles (clustering vertical tolérant).
@@ -271,6 +285,11 @@ def extract_situation_locataires_from_pdf(pdf_path: str) -> list[dict]:
_fill_totaux(current, row) _fill_totaux(current, row)
continue continue
# Ligne de total général de fin de tableau (« TOTAUX », tout en
# majuscules) : ne concerne aucun lot, à ignorer.
if loc == "TOTAUX":
continue
# Solde Antérieur (libellé + montant dans la colonne période). # Solde Antérieur (libellé + montant dans la colonne période).
if periode.startswith("Solde Antérieur"): if periode.startswith("Solde Antérieur"):
montant = _num(periode) montant = _num(periode)
@@ -307,14 +326,22 @@ def extract_situation_locataires_from_pdf(pdf_path: str) -> list[dict]:
) )
continue continue
# Ligne de période (loyer ou divers). # Ligne de période (loyer ou divers). Quand l'en-tête du lot est en bas
# d'une page et ses données en haut de la suivante, le nom du locataire
# peut se retrouver sur cette même ligne visuelle (colonne « Locataires »
# non vide) plutôt que sur la ligne d'en-tête du lot.
if _PERIODE_RE.search(periode): if _PERIODE_RE.search(periode):
if loc:
nom = current["locataire"]["nom"]
current["locataire"]["nom"] = f"{nom} {loc}".strip() if nom else loc
_append_periode_line(current, row) _append_periode_line(current, row)
continue continue
# Nom du locataire (cellule « Locataires » seule, nom pas encore trouvé). # Nom du locataire (cellule « Locataires » seule). Peut être réparti sur
if loc and not current["locataire"]["nom"]: # plusieurs lignes visuelles quand le nom est trop long pour la colonne.
current["locataire"]["nom"] = loc if loc:
nom = current["locataire"]["nom"]
current["locataire"]["nom"] = f"{nom} {loc}".strip() if nom else loc
if current: if current:
situations.append(current) situations.append(current)