Checkpoint: Ajout du bouton "Valider tout en BAP" sur la fenêtre Factures BAP : route validateBAPBulk côté serveur (traitement de toutes les factures éligibles en une passe), mutation côté client avec ouverture des PDFs générés, bouton vert dans le header de la page.

This commit is contained in:
Manus
2026-04-12 09:40:33 -04:00
parent d07ff1b34d
commit 76554214b2
3 changed files with 211 additions and 6 deletions

View File

@@ -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 } from "lucide-react"; import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck } from "lucide-react";
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { toast } from "sonner"; import { toast } from "sonner";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
@@ -131,6 +131,24 @@ export default function InvoicesBAP() {
}, },
}); });
const validateBAPBulkMutation = trpc.invoices.validateBAPBulk.useMutation({
onSuccess: (data) => {
if (data.processed === 0) {
toast.info("Aucune facture éligible à valider en BAP.");
} else {
toast.success(`${data.processed} facture(s) validée(s) BAP avec succès !`, { duration: 4000 });
const pdfUrls = (data.results as any[]).filter(r => r.pdfUrl).map(r => r.pdfUrl as string);
if (pdfUrls.length > 0) {
pdfUrls.forEach(url => window.open(url, '_blank'));
}
}
utils.invoices.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Erreur lors de la validation BAP en masse");
},
});
const validateBAPMutation = trpc.invoices.validateBAP.useMutation({ const validateBAPMutation = trpc.invoices.validateBAP.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
if (data.exportMode === 'browser' && data.pdfUrl) { if (data.exportMode === 'browser' && data.pdfUrl) {
@@ -403,6 +421,18 @@ export default function InvoicesBAP() {
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Supprimer ({selectedIds.length}) Supprimer ({selectedIds.length})
</Button> </Button>
<Button
onClick={() => {
if (confirm("Valider en BAP toutes les factures éligibles (score 100%, champs remplis, non abonnement) ? Les PDFs annotés seront générés automatiquement.")) {
validateBAPBulkMutation.mutate();
}
}}
disabled={validateBAPBulkMutation.isPending}
className="bg-green-700 hover:bg-green-800 text-white"
>
<ShieldCheck className="w-4 h-4 mr-2" />
{validateBAPBulkMutation.isPending ? "Validation en cours..." : "Valider tout en BAP"}
</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

View File

@@ -416,7 +416,7 @@ export const appRouter = router({
const fs = await import('fs/promises'); const fs = await import('fs/promises');
const path = await import('path'); const path = await import('path');
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib'); const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { storagePut } = await import('./storage'); const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id); const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser'; const bapExportMode = importSettings?.bapExportMode || 'browser';
@@ -584,9 +584,9 @@ export const appRouter = router({
exportPath = path.join(exportFolder, bapFilename); exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes); await fs.writeFile(exportPath, signedPdfBytes);
} else { } else {
// Mode navigateur : uploader sur S3 et retourner l'URL // Mode navigateur : stocker localement et retourner l'URL relative
const { storagePut: put } = await import('./storage'); const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await put(`bap-exports/${bapFilename}`, Buffer.from(signedPdfBytes), 'application/pdf'); const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url; pdfUrl = url;
} }
} catch (pdfError: any) { } catch (pdfError: any) {
@@ -629,7 +629,168 @@ export const appRouter = router({
}; };
}), }),
// ── Historique BAP ──────────────────────────────────────────────────── // ── Validation BAP en masse ──────────────────────────────
validateBAPBulk: protectedProcedure
.mutation(async ({ ctx }) => {
const allInvoices = await getInvoicesByUser(ctx.user.id);
// Filtrer les factures éligibles (non déjà validées)
const eligible = allInvoices.filter((inv: any) =>
(inv.qualityScore || 0) >= 100 &&
inv.isSubscription === 0 &&
!!inv.serviceConcerne &&
!!inv.typeAchat &&
!!inv.ventilationComptable &&
!inv.bapValidated
);
if (eligible.length === 0) {
return { success: true, processed: 0, errors: 0, results: [] };
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
const serviceSignaturesList = await getServiceSignaturesByUser(ctx.user.id);
const results: Array<{ id: number; success: boolean; pdfUrl?: string | null; exportPath?: string | null; error?: string }> = [];
let processed = 0;
let errors = 0;
const validatedAt = new Date();
for (const invoice of eligible) {
let pdfUrl: string | null = null;
let exportPath: string | null = null;
let signatureName: string | null = null;
try {
// ─ Lecture du PDF source ─
let sourcePdfBytes: Buffer;
const localPath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
try {
sourcePdfBytes = 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}`);
sourcePdfBytes = Buffer.from(await resp.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(sourcePdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width, height } = lastPage.getSize();
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
const zoneH = 120;
const zoneW = 200;
const zoneX = width - zoneW - 20;
const zoneY = 20;
lastPage.drawRectangle({
x: zoneX, y: zoneY, width: zoneW, height: zoneH,
color: rgb(1, 1, 1),
borderColor: rgb(0.2, 0.2, 0.2),
borderWidth: 1,
});
const typeAchatText = invoice.typeAchat || '';
lastPage.drawText(typeAchatText, {
x: zoneX + 10, y: zoneY + zoneH - 20,
size: 12, font: fontBold, color: rgb(0, 0, 0),
});
lastPage.drawText('BON À PAYER', {
x: zoneX + 10, y: zoneY + zoneH - 40,
size: 14, font: fontBold, color: rgb(0, 0.4, 0),
});
const recipientText = (invoice as any).recipientName || 'TOUS';
lastPage.drawText(recipientText, {
x: zoneX + 10, y: zoneY + zoneH - 60,
size: 10, font: fontNormal, color: rgb(0.2, 0.2, 0.2),
});
// Signature du service
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceSignaturesList.find(
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
);
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try {
sigImageBytes = await fs.readFile(sigImagePath);
} catch (_) {
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status}`);
sigImageBytes = Buffer.from(await sigResp.arrayBuffer());
}
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const embeddedSig = mimeType === 'image/png'
? await pdfDoc.embedPng(sigImageBytes)
: await pdfDoc.embedJpg(sigImageBytes);
lastPage.drawImage(embeddedSig, {
x: zoneX + zoneW - 110, y: zoneY + 20, width: 100, height: 45,
});
lastPage.drawText(signatureName, {
x: zoneX + zoneW - 110, y: zoneY + 12,
size: 8, font: fontNormal, color: rgb(0.3, 0.3, 0.3),
});
} catch (_) { /* ignore signature errors */ }
}
}
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
if (bapExportMode === 'folder' && exportFolder) {
await fs.mkdir(exportFolder, { recursive: true });
exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes);
} else {
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
}
} catch (pdfError: any) {
console.warn(`[BAP Bulk] Erreur PDF facture ${invoice.id}:`, pdfError.message);
}
// Mise à jour de la facture
await updateInvoice(invoice.id, { bapValidated: 1, bapValidatedAt: validatedAt });
// Historique
await createBapHistoryEntry({
userId: ctx.user.id,
invoiceId: invoice.id,
supplierName: invoice.supplierName || null,
invoiceNumber: invoice.invoiceNumber || null,
invoiceDate: invoice.invoiceDate || null,
totalAmount: invoice.totalAmount ? String(invoice.totalAmount) : null,
typeAchat: invoice.typeAchat || null,
serviceConcerne: invoice.serviceConcerne || null,
ventilationComptable: invoice.ventilationComptable || null,
recipientName: (invoice as any).recipientName || null,
exportMode: bapExportMode === 'folder' ? 'folder' : 'browser',
exportPath: exportPath || null,
pdfUrl: pdfUrl || null,
signatureName: signatureName || null,
validatedAt,
});
results.push({ id: invoice.id, success: true, pdfUrl, exportPath });
processed++;
}
return { success: true, processed, errors, results };
}),
// // ── Historique BAP ────────────────────
search: protectedProcedure search: protectedProcedure
.input(z.object({ query: z.string() })) .input(z.object({ query: z.string() }))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {

14
todo.md
View File

@@ -603,3 +603,17 @@
- [x] Corriger la lecture de l'image de signature dans validateBAP (fallback URL) - [x] Corriger la lecture de l'image de signature dans validateBAP (fallback URL)
- [x] Corriger la lecture du PDF source dans exportToPdf (fallback URL) - [x] Corriger la lecture du PDF source dans exportToPdf (fallback URL)
- [x] Corriger la lecture de l'image de signature dans exportToPdf (fallback URL) - [x] Corriger la lecture de l'image de signature dans exportToPdf (fallback URL)
## Correction table signatures VPS
- [x] Corriger noms de colonnes dans signatures et service_signatures sur le VPS (snake_case -> camelCase)
## Correction affectation signature à service (serviceSignatures)
- [x] Corriger l'upsert serviceSignatures (renommage table service_signatures -> serviceSignatures sur le VPS)
## Correction PDF BAP non affiché sur VPS
- [ ] Diagnostiquer et corriger la génération du PDF annoté lors de la validation BAP sur le VPS
## Validation BAP en masse
- [ ] Ajouter la route validateBAPBulk côté serveur (traitement séquentiel de toutes les factures éligibles)
- [ ] Ajouter le bouton "Valider tout en BAP" dans InvoicesBAP.tsx avec barre de progression
- [ ] Déployer sur le VPS