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({
|
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>
|
||||||
|
|||||||
@@ -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() }))
|
||||||
|
|||||||
Reference in New Issue
Block a user