diff --git a/.manus/db/db-query-1781681544555.json b/.manus/db/db-query-1781681544555.json new file mode 100644 index 0000000..fad826b --- /dev/null +++ b/.manus/db/db-query-1781681544555.json @@ -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 +} \ No newline at end of file diff --git a/client/src/pages/InvoicesBAP.tsx b/client/src/pages/InvoicesBAP.tsx index 4cb132f..18807e1 100644 --- a/client/src/pages/InvoicesBAP.tsx +++ b/client/src/pages/InvoicesBAP.tsx @@ -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() { )} diff --git a/server/routers.ts b/server/routers.ts index f0744e5..949f484 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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() }))