Checkpoint: Fix: bouton de téléchargement PDF BAP pour factures validées sans PDF (NOVRH VF11521, EVOLUCARE ET098857). Ajout procédure regenerateBapPdf côté serveur + bouton orange RefreshCw côté frontend pour régénérer le PDF à la volée. Déployé en recette et production.

This commit is contained in:
Manus
2026-06-17 03:41:18 -04:00
parent 756f8ab392
commit b97efb5c57
3 changed files with 139 additions and 4 deletions

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.bd4328423008 --database fo4DRyBgjsuiigFAgNLuWm --execute \nSELECT i.id, i.supplierName, i.invoiceNumber, i.bapValidated, i.exportStatus, \n bh.id as bapHistoryId, bh.pdfUrl, bh.validatedAt\nFROM invoices i \nLEFT JOIN bapHistory bh ON bh.invoiceId = i.id \nWHERE i.supplierName LIKE '%ALPES%' OR i.supplierName LIKE '%KOESIO%'\nORDER BY i.supplierName;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 287
}

View File

@@ -289,6 +289,21 @@ export default function InvoicesBAP() {
}, },
}); });
const regenerateBapPdfMutation = trpc.invoices.regenerateBapPdf.useMutation({
onSuccess: (data, variables) => {
if (data.pdfUrl) {
setBapPdfUrls(prev => ({ ...prev, [variables.invoiceId]: data.pdfUrl as string }));
toast.success('PDF BAP régénéré avec succès !');
downloadBapPdf(data.pdfUrl);
}
utils.invoices.list.invalidate();
utils.invoices.getBapPdfUrls.invalidate();
},
onError: (error) => {
toast.error(error.message || 'Erreur lors de la régénération du PDF BAP');
},
});
const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({ const devalidateBAPMutation = trpc.invoices.devalidateBAP.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`); toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
@@ -1000,11 +1015,14 @@ export default function InvoicesBAP() {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed" className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
title="PDF non disponible" title="PDF non disponible — cliquer pour régénérer"
disabled disabled={regenerateBapPdfMutation.isPending}
onClick={() => regenerateBapPdfMutation.mutate({ invoiceId: invoice.id })}
> >
<Download className="h-4 w-4" /> {regenerateBapPdfMutation.isPending && regenerateBapPdfMutation.variables?.invoiceId === invoice.id
? <RefreshCw className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -887,6 +887,114 @@ export const appRouter = router({
return { success: true, processed, errors, results }; return { success: true, processed, errors, results };
}), }),
// ── Régénérer le PDF BAP pour une facture validée sans PDF ─────────────────
regenerateBapPdf: protectedProcedure
.input(z.object({ invoiceId: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.invoiceId);
if (!invoice || invoice.userId !== ctx.user.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Facture introuvable' });
}
if (!invoice.bapValidated) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'La facture n\'est pas validée BAP' });
}
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument } = await import('pdf-lib');
const { localStoragePut, generateStorageKey } = await import('./localStorage');
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
if (!invoice.fileKey && !invoice.fileUrl) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
}
let pdfBytes: Buffer;
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey || '');
try {
pdfBytes = await fs.readFile(sourcePath);
} catch (_) {
const fileUrl = invoice.fileUrl;
if (!fileUrl) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Fichier PDF source introuvable' });
let absoluteUrl = fileUrl;
if (fileUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteUrl = `${baseUrl}${fileUrl}`;
}
const response = await fetch(absoluteUrl);
if (!response.ok) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Impossible de télécharger le PDF source' });
pdfBytes = Buffer.from(await response.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(pdfBytes);
// Récupération de la signature du service
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: 'image/png' | 'image/jpeg' | undefined;
let signatureName: string | null = null;
if (invoice.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (invoice.serviceConcerne || '').toLowerCase());
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { sigBytesForCartouche = await fs.readFile(sigImagePath); } catch (_) {
const sigUrl = sig.imageUrl;
if (sigUrl) {
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) sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
}
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// Placement du cartouche BAP
const validatedAt = invoice.bapValidatedAt ? new Date(invoice.bapValidatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt,
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const _bapDateStr = invoice.invoiceDate ? new Date(invoice.invoiceDate).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const _bapSupplier = (invoice.supplierName || 'Fournisseur').replace(/[^a-zA-Z0-9\u00e0-\u00ff \-]/g, '').trim();
const _bapNumber = (invoice.invoiceNumber || '').replace(/[^a-zA-Z0-9\-]/g, '').trim();
const bapFilename = [_bapDateStr, _bapSupplier, _bapNumber].filter(Boolean).join(' - ') + '.pdf';
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
// Mettre à jour la dernière entrée bapHistory de cette facture avec le nouveau pdfUrl
const allEntries = await getBapHistoryByUser(ctx.user.id);
const latestEntry = allEntries
.filter(e => e.invoiceId === input.invoiceId)
.sort((a, b) => new Date(b.validatedAt).getTime() - new Date(a.validatedAt).getTime())[0];
if (latestEntry) {
await updateBapHistoryPdfUrl(latestEntry.id, url);
}
// Mettre à jour le statut export de la facture
await updateInvoice(input.invoiceId, { exportStatus: 'exported' as any });
return { success: true, pdfUrl: url };
}),
// // ── Historique BAP ──────────────────── // // ── Historique BAP ────────────────────
search: protectedProcedure search: protectedProcedure
.input(z.object({ query: z.string() })) .input(z.object({ query: z.string() }))