Checkpoint: Ajout route tRPC invoices.reprocessSelected et bouton Relancer (violet) dans la barre d'outils de Factures BAP
This commit is contained in:
@@ -32,7 +32,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck } from "lucide-react";
|
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
// Helper : télécharge un PDF annoté BAP depuis son URL de stockage
|
// Helper : télécharge un PDF annoté BAP depuis son URL de stockage
|
||||||
// Nommage : AAAA-MM-JJ - Fournisseur - N°Facture.pdf
|
// Nommage : AAAA-MM-JJ - Fournisseur - N°Facture.pdf
|
||||||
@@ -158,6 +158,21 @@ export default function InvoicesBAP() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reprocessMutation = trpc.invoices.reprocessSelected.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.errors > 0) {
|
||||||
|
toast.warning(`${data.processed} facture(s) retraitée(s), ${data.errors} erreur(s).`, { duration: 5000 });
|
||||||
|
} else {
|
||||||
|
toast.success(`${data.processed} facture(s) retraitée(s) avec succès !`, { duration: 4000 });
|
||||||
|
}
|
||||||
|
setSelectedIds([]);
|
||||||
|
utils.invoices.list.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Erreur lors du retraitement");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const validateBAPBulkMutation = trpc.invoices.validateBAPBulk.useMutation({
|
const validateBAPBulkMutation = trpc.invoices.validateBAPBulk.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.processed === 0) {
|
if (data.processed === 0) {
|
||||||
@@ -464,6 +479,23 @@ export default function InvoicesBAP() {
|
|||||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||||
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
|
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
toast.error("Veuillez sélectionner au moins une facture");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (confirm(`Relancer l'analyse IA et les automatismes sur ${selectedIds.length} facture(s) sélectionnée(s) ?\nCela peut prendre quelques secondes par facture.`)) {
|
||||||
|
reprocessMutation.mutate({ invoiceIds: selectedIds });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={selectedIds.length === 0 || reprocessMutation.isPending}
|
||||||
|
variant="outline"
|
||||||
|
className="border-purple-500 text-purple-600 hover:bg-purple-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${reprocessMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
{reprocessMutation.isPending ? `Retraitement...` : `Relancer (${selectedIds.length})`}
|
||||||
|
</Button>
|
||||||
<Button onClick={() => setLocation("/upload")}>
|
<Button onClick={() => setLocation("/upload")}>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
Importer
|
Importer
|
||||||
|
|||||||
@@ -864,6 +864,106 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
return { success: true, processed, errors, results };
|
return { success: true, processed, errors, results };
|
||||||
}),
|
}),
|
||||||
|
// ── Relancer les automatismes sur une sélection ─────────────────
|
||||||
|
reprocessSelected: protectedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
invoiceIds: z.array(z.number()).min(1),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { applyAutomationRules } = await import("./automationEngine");
|
||||||
|
const { localStoragePut, generateStorageKey } = await import('./localStorage');
|
||||||
|
const path = await import('path');
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
|
||||||
|
const allInvoices = await getInvoicesByUser(ctx.user.id);
|
||||||
|
const selected = allInvoices.filter(inv => input.invoiceIds.includes(inv.id));
|
||||||
|
if (selected.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune facture trouvée' });
|
||||||
|
|
||||||
|
const userSettings = await getUserSettings(ctx.user.id);
|
||||||
|
const model = userSettings?.llmModel || 'mistral-large-latest';
|
||||||
|
const customKeywords = {
|
||||||
|
invoiceNumber: userSettings?.invoiceNumberKeywords || null,
|
||||||
|
deliveryNote: userSettings?.deliveryNoteKeywords || null,
|
||||||
|
orderNumber: userSettings?.orderNumberKeywords || null,
|
||||||
|
supplier: userSettings?.supplierKeywords || null,
|
||||||
|
totalAmount: userSettings?.totalAmountKeywords || null,
|
||||||
|
subscription: userSettings?.subscriptionKeywords || null,
|
||||||
|
recipient: userSettings?.recipientKeywords || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
let errors = 0;
|
||||||
|
const results: Array<{ id: number; success: boolean; qualityScore?: number; error?: string }> = [];
|
||||||
|
|
||||||
|
for (const invoice of selected) {
|
||||||
|
try {
|
||||||
|
// Lire le PDF source
|
||||||
|
let pdfBuffer: Buffer;
|
||||||
|
const localPath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
|
||||||
|
try {
|
||||||
|
pdfBuffer = await fs.readFile(localPath);
|
||||||
|
} catch (_) {
|
||||||
|
const fileUrl = invoice.fileUrl;
|
||||||
|
if (!fileUrl) throw new Error('Fichier PDF introuvable');
|
||||||
|
let absoluteUrl = fileUrl;
|
||||||
|
if (fileUrl.startsWith('/')) {
|
||||||
|
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||||
|
absoluteUrl = `${baseUrl}${fileUrl}`;
|
||||||
|
}
|
||||||
|
const resp = await fetch(absoluteUrl);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
pdfBuffer = Buffer.from(await resp.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relancer l'extraction LLM
|
||||||
|
const result = await extractInvoicesWithMistral(pdfBuffer, ctx.user.id, invoice.sourceFileId!, model, customKeywords);
|
||||||
|
|
||||||
|
// Prendre la facture correspondante dans le résultat (par index ou la première)
|
||||||
|
const idx = (invoice.invoiceIndexInFile || 1) - 1;
|
||||||
|
const extracted = result.invoices[idx] || result.invoices[0];
|
||||||
|
if (!extracted) throw new Error('Extraction vide');
|
||||||
|
|
||||||
|
// Mettre à jour les champs extraits
|
||||||
|
const metadataJson = generateMetadataJSON(extracted);
|
||||||
|
const metadataKey = generateStorageKey(ctx.user.id, `${invoice.fileName}-reprocess-metadata.json`);
|
||||||
|
const { url: metadataUrl } = await localStoragePut(metadataKey, Buffer.from(metadataJson), 'application/json');
|
||||||
|
|
||||||
|
await updateInvoice(invoice.id, {
|
||||||
|
supplierName: extracted.supplierName,
|
||||||
|
invoiceNumber: extracted.invoiceNumber,
|
||||||
|
invoiceDate: extracted.invoiceDate,
|
||||||
|
deliveryNoteNumber: extracted.deliveryNoteNumber,
|
||||||
|
orderNumber: extracted.orderNumber,
|
||||||
|
totalAmount: extracted.totalAmount?.toString(),
|
||||||
|
recipientName: extracted.recipientName,
|
||||||
|
qualityScore: extracted.qualityScore,
|
||||||
|
extractedText: extracted.extractedText,
|
||||||
|
isSubscription: extracted.isSubscription ? 1 : 0,
|
||||||
|
metadataFileKey: metadataKey,
|
||||||
|
metadataFileUrl: metadataUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Réappliquer les règles d'automatisme
|
||||||
|
const updatedInvoice = await getInvoiceById(invoice.id);
|
||||||
|
if (updatedInvoice) {
|
||||||
|
const automationUpdates = await applyAutomationRules(ctx.user.id, updatedInvoice);
|
||||||
|
if (Object.keys(automationUpdates).length > 0) {
|
||||||
|
await updateInvoice(invoice.id, automationUpdates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ id: invoice.id, success: true, qualityScore: extracted.qualityScore });
|
||||||
|
processed++;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Reprocess] Error on invoice ${invoice.id}:`, err);
|
||||||
|
results.push({ id: invoice.id, success: false, error: err.message });
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, processed, errors, results };
|
||||||
|
}),
|
||||||
|
|
||||||
// // ── Historique BAP ────────────────────
|
// // ── Historique BAP ────────────────────
|
||||||
search: protectedProcedure
|
search: protectedProcedure
|
||||||
.input(z.object({ query: z.string() }))
|
.input(z.object({ query: z.string() }))
|
||||||
|
|||||||
Reference in New Issue
Block a user