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:
@@ -64,7 +64,7 @@
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Immeuble</th>
|
||||
<th>Date</th>
|
||||
<th>Date / extraction</th>
|
||||
<th class="text-right">Solde</th>
|
||||
<th class="text-center">Fichiers</th>
|
||||
<th class="text-right">Actions</th>
|
||||
@@ -100,7 +100,12 @@
|
||||
<!-- Date -->
|
||||
<td>
|
||||
<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>
|
||||
|
||||
<!-- Solde -->
|
||||
|
||||
@@ -320,6 +320,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ExtractionDiffDetails from '../components/ExtractionDiffDetails.vue'
|
||||
import TagAutocomplete from '../components/TagAutocomplete.vue'
|
||||
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.
|
||||
const SECONDS_PER_DOC = 7
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const documents = ref([])
|
||||
const items = ref([])
|
||||
const availableTags = ref([])
|
||||
@@ -663,8 +666,11 @@ async function applySelection() {
|
||||
|
||||
isApplying.value = false
|
||||
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' : ''}`
|
||||
return
|
||||
}
|
||||
router.push('/documents')
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
|
||||
@@ -195,6 +195,7 @@ async def list_documents(
|
||||
solde_montant=doc.solde_montant,
|
||||
solde_type=doc.solde_type,
|
||||
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_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,
|
||||
"depenses_tags": db_service.get_depenses_tags(document_id),
|
||||
"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_json": document.json_path is not None,
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ class DocumentSummary(BaseModel):
|
||||
solde_montant: float | None
|
||||
solde_type: str | None
|
||||
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_json: bool = False
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ def init_db(db_path: Path | None = None) -> Path:
|
||||
# Build the engine (reuses the shared configuration) and create tables
|
||||
engine = get_engine(db_path)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_apply_schema_updates(engine)
|
||||
|
||||
# Seed predefined tags if the table is empty
|
||||
_seed_tags_if_empty(engine)
|
||||
@@ -94,6 +95,34 @@ def init_db(db_path: Path | None = None) -> 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):
|
||||
"""Insert predefined tags if the tags table is empty."""
|
||||
from ..scripts.seed_tags import PREDEFINED_TAGS
|
||||
|
||||
@@ -161,6 +161,10 @@ class Document(Base):
|
||||
solde_date_arrete = Column(Date, nullable=True)
|
||||
|
||||
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)
|
||||
pdf_path = Column(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Database service for saving and querying extracted data."""
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -249,6 +249,8 @@ class DatabaseService:
|
||||
"solde_date_arrete": self._parse_date(solde_info.get("date_arrete")),
|
||||
"pdf_path": pdf_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:
|
||||
document = reused
|
||||
|
||||
73
tests/test_schema_updates.py
Normal file
73
tests/test_schema_updates.py
Normal 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()
|
||||
Reference in New Issue
Block a user