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:
@@ -416,7 +416,7 @@ export const appRouter = router({
|
||||
const fs = await import('fs/promises');
|
||||
const path = await import('path');
|
||||
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 bapExportMode = importSettings?.bapExportMode || 'browser';
|
||||
@@ -584,9 +584,9 @@ export const appRouter = router({
|
||||
exportPath = path.join(exportFolder, bapFilename);
|
||||
await fs.writeFile(exportPath, signedPdfBytes);
|
||||
} else {
|
||||
// Mode navigateur : uploader sur S3 et retourner l'URL
|
||||
const { storagePut: put } = await import('./storage');
|
||||
const { url } = await put(`bap-exports/${bapFilename}`, Buffer.from(signedPdfBytes), 'application/pdf');
|
||||
// Mode navigateur : stocker localement et retourner l'URL relative
|
||||
const bapKey = generateStorageKey(ctx.user.id, bapFilename);
|
||||
const { url } = await localStoragePut(bapKey, Buffer.from(signedPdfBytes), 'application/pdf');
|
||||
pdfUrl = url;
|
||||
}
|
||||
} 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
|
||||
.input(z.object({ query: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
Reference in New Issue
Block a user