feat: affiche la date de derniere extraction et revient aux documents

La liste des documents montrait la date d'import, figee au premier
enregistrement : c'est la date de la derniere extraction ayant produit les
donnees affichees qui renseigne, surtout apres un balayage de re-extraction. La
colonne `extracted_at` la porte, mise a jour a chaque enregistrement, et la date
d'import reste consultable en infobulle.

Le projet n'ayant pas d'outil de migration (`create_all` laisse intactes les
tables existantes), `init_db` rattrape les colonnes ajoutees apres coup : sans
cela une base deja installee cesserait de fonctionner. Les documents deja
presents recoivent leur date d'import, qui est bien celle de leur extraction.

Une fois la selection appliquee sans echec, le balayage renvoie vers la liste des
documents ; en cas d'echec on reste sur place, les lignes concernees portant leur
message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 06:52:49 +02:00
parent a1da4fc97f
commit 92cf21309f
8 changed files with 129 additions and 3 deletions

View File

@@ -64,7 +64,7 @@
<tr> <tr>
<th>Document</th> <th>Document</th>
<th>Immeuble</th> <th>Immeuble</th>
<th>Date</th> <th>Date / extraction</th>
<th class="text-right">Solde</th> <th class="text-right">Solde</th>
<th class="text-center">Fichiers</th> <th class="text-center">Fichiers</th>
<th class="text-right">Actions</th> <th class="text-right">Actions</th>
@@ -100,7 +100,12 @@
<!-- Date --> <!-- Date -->
<td> <td>
<div class="text-sm text-white">{{ formatDate(doc.date) }}</div> <div class="text-sm text-white">{{ formatDate(doc.date) }}</div>
<div class="text-xs text-gray-500">Importe le {{ formatDateTime(doc.created_at) }}</div> <div
class="text-xs text-gray-500"
:title="`Importé le ${formatDateTime(doc.created_at)}`"
>
Extrait le {{ formatDateTime(doc.extracted_at || doc.created_at) }}
</div>
</td> </td>
<!-- Solde --> <!-- Solde -->

View File

@@ -320,6 +320,7 @@
<script setup> <script setup>
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue' import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue' import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
import TagAutocomplete from '../components/TagAutocomplete.vue' import TagAutocomplete from '../components/TagAutocomplete.vue'
import { import {
@@ -333,6 +334,8 @@ import { fillPredictions, remapTags, toDepensesTags } from '../utils/tagRemap.js
// Ordre de grandeur mesuré d'une extraction, avant toute mesure réelle. // Ordre de grandeur mesuré d'une extraction, avant toute mesure réelle.
const SECONDS_PER_DOC = 7 const SECONDS_PER_DOC = 7
const router = useRouter()
const documents = ref([]) const documents = ref([])
const items = ref([]) const items = ref([])
const availableTags = ref([]) const availableTags = ref([])
@@ -663,8 +666,11 @@ async function applySelection() {
isApplying.value = false isApplying.value = false
if (failures) { if (failures) {
// On reste sur la page : les lignes en échec portent leur message.
applyError.value = `${failures} document${failures > 1 ? 's' : ''} n'${failures > 1 ? 'ont' : 'a'} pas pu être enregistré${failures > 1 ? 's' : ''}` applyError.value = `${failures} document${failures > 1 ? 's' : ''} n'${failures > 1 ? 'ont' : 'a'} pas pu être enregistré${failures > 1 ? 's' : ''}`
return
} }
router.push('/documents')
} }
function formatDate(dateStr) { function formatDate(dateStr) {

View File

@@ -195,6 +195,7 @@ async def list_documents(
solde_montant=doc.solde_montant, solde_montant=doc.solde_montant,
solde_type=doc.solde_type, solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None, created_at=doc.created_at.isoformat() if doc.created_at else None,
extracted_at=doc.extracted_at.isoformat() if doc.extracted_at else None,
has_pdf=doc.pdf_path is not None, has_pdf=doc.pdf_path is not None,
has_json=doc.json_path is not None, has_json=doc.json_path is not None,
) )
@@ -245,6 +246,9 @@ async def get_document(
"json_data": json.loads(document.json_data) if document.json_data else None, "json_data": json.loads(document.json_data) if document.json_data else None,
"depenses_tags": db_service.get_depenses_tags(document_id), "depenses_tags": db_service.get_depenses_tags(document_id),
"created_at": document.created_at.isoformat() if document.created_at else None, "created_at": document.created_at.isoformat() if document.created_at else None,
"extracted_at": document.extracted_at.isoformat()
if document.extracted_at
else None,
"has_pdf": document.pdf_path is not None, "has_pdf": document.pdf_path is not None,
"has_json": document.json_path is not None, "has_json": document.json_path is not None,
} }

View File

@@ -53,6 +53,9 @@ class DocumentSummary(BaseModel):
solde_montant: float | None solde_montant: float | None
solde_type: str | None solde_type: str | None
created_at: str created_at: str
#: Derniere extraction ayant produit les donnees (absente sur une base
#: anterieure a ce champ : l'appelant retombe alors sur `created_at`).
extracted_at: str | None = None
has_pdf: bool = False has_pdf: bool = False
has_json: bool = False has_json: bool = False

View File

@@ -87,6 +87,7 @@ def init_db(db_path: Path | None = None) -> Path:
# Build the engine (reuses the shared configuration) and create tables # Build the engine (reuses the shared configuration) and create tables
engine = get_engine(db_path) engine = get_engine(db_path)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_apply_schema_updates(engine)
# Seed predefined tags if the table is empty # Seed predefined tags if the table is empty
_seed_tags_if_empty(engine) _seed_tags_if_empty(engine)
@@ -94,6 +95,34 @@ def init_db(db_path: Path | None = None) -> Path:
return db_path return db_path
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour.
_ADDED_COLUMNS = {
"documents": {
# Les documents deja en base ont ete extraits lors de leur import.
"extracted_at": ("DATETIME", "created_at"),
},
}
def _apply_schema_updates(engine):
"""Ajoute a une base existante les colonnes apparues depuis sa creation."""
with engine.begin() as conn:
for table, columns in _ADDED_COLUMNS.items():
existing = {
row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))
}
if not existing: # table absente : create_all vient de la creer
continue
for name, (definition, backfill) in columns.items():
if name in existing:
continue
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}"))
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))
def _seed_tags_if_empty(engine): def _seed_tags_if_empty(engine):
"""Insert predefined tags if the tags table is empty.""" """Insert predefined tags if the tags table is empty."""
from ..scripts.seed_tags import PREDEFINED_TAGS from ..scripts.seed_tags import PREDEFINED_TAGS

View File

@@ -161,6 +161,10 @@ class Document(Base):
solde_date_arrete = Column(Date, nullable=True) solde_date_arrete = Column(Date, nullable=True)
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
#: Date de la derniere extraction ayant produit les donnees stockees. Une
#: re-extraction validee la met a jour, contrairement a `created_at` qui
#: reste la date du premier import.
extracted_at = Column(DateTime, default=_utcnow)
# Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH) # Chemins vers les fichiers stockés (relatifs à PLESNA_STORAGE_PATH)
pdf_path = Column( pdf_path = Column(

View File

@@ -1,7 +1,7 @@
"""Database service for saving and querying extracted data.""" """Database service for saving and querying extracted data."""
import json import json
from datetime import date, datetime from datetime import date, datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
@@ -249,6 +249,8 @@ class DatabaseService:
"solde_date_arrete": self._parse_date(solde_info.get("date_arrete")), "solde_date_arrete": self._parse_date(solde_info.get("date_arrete")),
"pdf_path": pdf_path, "pdf_path": pdf_path,
"json_path": json_path, "json_path": json_path,
# Ces donnees viennent de l'extraction qu'on est en train d'enregistrer.
"extracted_at": datetime.now(timezone.utc),
} }
if reused is not None: if reused is not None:
document = reused document = reused

View File

@@ -0,0 +1,73 @@
"""Rattrapage de schema sur une base creee avant l'ajout d'une colonne.
Le projet n'a pas d'outil de migration : `create_all` laisse intactes les tables
deja presentes. Sans le rattrapage d'`init_db`, une base installee cesserait de
fonctionner des qu'une colonne est ajoutee au modele.
"""
from datetime import datetime, timezone
from sqlalchemy import text
from plesna_gerance.database import connection
def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
db_path = tmp_path / "ancienne.sqlite"
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
# Base telle qu'elle existait avant le champ, avec un document dedans.
connection.reset_connection()
connection.init_db(db_path)
engine = connection.get_engine(db_path)
importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
conn.execute(
text(
"INSERT INTO immeubles (code) VALUES ('IMM1');"
)
)
conn.execute(
text(
"INSERT INTO documents (reference, date, immeuble_id, json_data,"
" created_at) VALUES ('REF', '2026-03-01', 1, '{}', :created)"
),
{"created": importe_le},
)
connection.reset_connection()
connection.init_db(db_path)
with connection.get_engine(db_path).begin() as conn:
colonnes = {
row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))
}
assert "extracted_at" in colonnes
# Un document deja en base a ete extrait lors de son import.
extracted_at, created_at = conn.execute(
text("SELECT extracted_at, created_at FROM documents")
).one()
assert extracted_at == created_at
connection.reset_connection()
def test_rattrapage_idempotent(tmp_path, monkeypatch):
"""Relancer init_db sur une base a jour ne doit rien casser."""
db_path = tmp_path / "a_jour.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)
connection.reset_connection()
connection.init_db(db_path)
with connection.get_engine(db_path).begin() as conn:
colonnes = [row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))]
assert colonnes.count("extracted_at") == 1
connection.reset_connection()