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:
@@ -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
|
||||||
|
|||||||
@@ -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
14
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user