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:
9
.manus/db/db-query-1781681544555.json
Normal file
9
.manus/db/db-query-1781681544555.json
Normal 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
|
||||
}
|
||||
@@ -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({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.processed} facture(s) dévalidée(s) BAP avec succès`);
|
||||
@@ -1000,11 +1015,14 @@ export default function InvoicesBAP() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 px-2 text-gray-400 border-gray-200 cursor-not-allowed"
|
||||
title="PDF non disponible"
|
||||
disabled
|
||||
className="h-8 px-2 text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
title="PDF non disponible — cliquer pour régénérer"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -887,6 +887,114 @@ export const appRouter = router({
|
||||
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 ────────────────────
|
||||
search: protectedProcedure
|
||||
.input(z.object({ query: z.string() }))
|
||||
|
||||
Reference in New Issue
Block a user