feat: Ajouter script migration PDF avenants Airtable → S3
- Téléchargement des PDF depuis URLs Airtable
- Upload vers S3 avec pattern avenants/{reference}_avenant_{timestamp}.pdf
- Mise à jour de pdf_s3_key et pdf_url dans Supabase
- 91 PDF migrés avec succès
This commit is contained in:
parent
69cb2c5a0a
commit
26132f38ce
1 changed files with 331 additions and 0 deletions
331
scripts/migrate-avenants-pdf-from-airtable.ts
Normal file
331
scripts/migrate-avenants-pdf-from-airtable.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
/**
|
||||||
|
* Script de migration des PDF des avenants depuis Airtable vers S3
|
||||||
|
*
|
||||||
|
* Ce script télécharge les PDF des avenants depuis Airtable et les uploade sur S3,
|
||||||
|
* puis met à jour la base de données Supabase avec les URLs.
|
||||||
|
*
|
||||||
|
* Prérequis:
|
||||||
|
* - Le CSV doit avoir les colonnes: Reference, Avenant PDF
|
||||||
|
* - Les variables d'environnement AWS et Supabase doivent être configurées
|
||||||
|
*
|
||||||
|
* Utilisation:
|
||||||
|
* npx tsx scripts/migrate-avenants-pdf-from-airtable.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as readline from 'readline';
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
// Charger les variables d'environnement
|
||||||
|
config({ path: path.join(process.cwd(), '.env.local') });
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
const CSV_FILE_PATH = path.join(process.cwd(), 'Contrats-PDF-Avenants.csv');
|
||||||
|
|
||||||
|
// AWS S3 Configuration
|
||||||
|
const AWS_REGION = process.env.AWS_REGION || 'eu-west-3';
|
||||||
|
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID!;
|
||||||
|
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY!;
|
||||||
|
const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME || 'odentas-documents';
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_SERVICE_KEY) {
|
||||||
|
console.error('❌ Variables d\'environnement Supabase manquantes');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY) {
|
||||||
|
console.error('❌ Variables d\'environnement AWS manquantes');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
region: AWS_REGION,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AirtableAvenantPdfRow {
|
||||||
|
reference: string; // Référence du contrat
|
||||||
|
avenant_pdf_url: string; // URL du PDF de l'avenant
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit et parse le fichier CSV
|
||||||
|
*/
|
||||||
|
async function readCSV(): Promise<AirtableAvenantPdfRow[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const rows: AirtableAvenantPdfRow[] = [];
|
||||||
|
const fileStream = fs.createReadStream(CSV_FILE_PATH);
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity
|
||||||
|
});
|
||||||
|
|
||||||
|
let isFirstLine = true;
|
||||||
|
let pdfColumnIndex = -1;
|
||||||
|
let referenceColumnIndex = -1;
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
// Parser la première ligne pour trouver les index des colonnes
|
||||||
|
if (isFirstLine) {
|
||||||
|
isFirstLine = false;
|
||||||
|
const matches = line.match(/(?:^|,)("(?:[^"]|"")*"|[^,]*)/g);
|
||||||
|
if (!matches) return;
|
||||||
|
|
||||||
|
const cols = matches.map(m => {
|
||||||
|
let val = m.replace(/^,/, '');
|
||||||
|
if (val.startsWith('"') && val.endsWith('"')) {
|
||||||
|
val = val.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return val.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
referenceColumnIndex = cols.indexOf('Reference');
|
||||||
|
pdfColumnIndex = cols.indexOf('Avenant PDF');
|
||||||
|
|
||||||
|
if (referenceColumnIndex === -1 || pdfColumnIndex === -1) {
|
||||||
|
console.error('❌ Colonnes "Reference" ou "Avenant PDF" non trouvées');
|
||||||
|
reject(new Error('Colonnes manquantes dans le CSV'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les lignes de données
|
||||||
|
const matches = line.match(/(?:^|,)("(?:[^"]|"")*"|[^,]*)/g);
|
||||||
|
if (!matches || matches.length <= Math.max(referenceColumnIndex, pdfColumnIndex)) return;
|
||||||
|
|
||||||
|
const cols = matches.map(m => {
|
||||||
|
let val = m.replace(/^,/, '');
|
||||||
|
if (val.startsWith('"') && val.endsWith('"')) {
|
||||||
|
val = val.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return val.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const reference = cols[referenceColumnIndex] || '';
|
||||||
|
let pdfUrl = cols[pdfColumnIndex] || '';
|
||||||
|
|
||||||
|
// Extraire l'URL si le format est "nom_fichier.pdf (url)"
|
||||||
|
const urlMatch = pdfUrl.match(/\(https?:\/\/[^)]+\)/);
|
||||||
|
if (urlMatch) {
|
||||||
|
pdfUrl = urlMatch[0].slice(1, -1); // Retirer les parenthèses
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference && pdfUrl && pdfUrl.startsWith('http')) {
|
||||||
|
rows.push({
|
||||||
|
reference,
|
||||||
|
avenant_pdf_url: pdfUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => resolve(rows));
|
||||||
|
rl.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge un fichier depuis une URL
|
||||||
|
*/
|
||||||
|
async function downloadFile(url: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const protocol = url.startsWith('https') ? https : http;
|
||||||
|
|
||||||
|
protocol.get(url, (response) => {
|
||||||
|
if (response.statusCode === 301 || response.statusCode === 302) {
|
||||||
|
// Suivre la redirection
|
||||||
|
if (response.headers.location) {
|
||||||
|
downloadFile(response.headers.location).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
reject(new Error(`Erreur HTTP: ${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
response.on('error', reject);
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload un fichier sur S3
|
||||||
|
*/
|
||||||
|
async function uploadToS3(buffer: Buffer, key: string, contentType: string): Promise<string> {
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: S3_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3Client.send(command);
|
||||||
|
|
||||||
|
// Retourner l'URL publique (si le bucket est public) ou une URL signée
|
||||||
|
return `https://${S3_BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche les avenants d'un contrat
|
||||||
|
*/
|
||||||
|
async function findAvenantsForContract(contractReference: string): Promise<any[]> {
|
||||||
|
const { data: contract } = await supabase
|
||||||
|
.from('cddu_contracts')
|
||||||
|
.select('id')
|
||||||
|
.or(`contract_number.eq.${contractReference},reference.eq.${contractReference}`)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: avenants } = await supabase
|
||||||
|
.from('avenants')
|
||||||
|
.select('id, numero_avenant, date_avenant, pdf_s3_key')
|
||||||
|
.eq('contract_id', contract.id)
|
||||||
|
.order('date_avenant', { ascending: true });
|
||||||
|
|
||||||
|
return avenants || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour un avenant avec le PDF
|
||||||
|
*/
|
||||||
|
async function updateAvenantWithPdf(avenantId: string, s3Key: string, pdfUrl: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('avenants')
|
||||||
|
.update({
|
||||||
|
pdf_s3_key: s3Key,
|
||||||
|
pdf_url: pdfUrl,
|
||||||
|
})
|
||||||
|
.eq('id', avenantId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fonction principale de migration
|
||||||
|
*/
|
||||||
|
async function migrate() {
|
||||||
|
console.log('🚀 Début de la migration des PDF des avenants depuis Airtable\n');
|
||||||
|
|
||||||
|
// Vérifier que le fichier CSV existe
|
||||||
|
if (!fs.existsSync(CSV_FILE_PATH)) {
|
||||||
|
console.error(`❌ Fichier CSV non trouvé: ${CSV_FILE_PATH}`);
|
||||||
|
console.error('📝 Placez le fichier "Contrats-PDF-Avenants.csv" à la racine du projet');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📄 Lecture du fichier: ${CSV_FILE_PATH}\n`);
|
||||||
|
|
||||||
|
const rows = await readCSV();
|
||||||
|
console.log(`📊 Nombre de lignes avec PDF: ${rows.length}\n`);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: rows.length,
|
||||||
|
success: 0,
|
||||||
|
noAvenants: 0,
|
||||||
|
downloadError: 0,
|
||||||
|
uploadError: 0,
|
||||||
|
updateError: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
const index = i + 1;
|
||||||
|
|
||||||
|
console.log(`\n[${index}/${rows.length}] Traitement de ${row.reference}`);
|
||||||
|
|
||||||
|
// Rechercher les avenants pour ce contrat
|
||||||
|
const avenants = await findAvenantsForContract(row.reference);
|
||||||
|
|
||||||
|
if (avenants.length === 0) {
|
||||||
|
console.log(` ⚠️ Aucun avenant trouvé pour ce contrat`);
|
||||||
|
stats.noAvenants++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ ${avenants.length} avenant(s) trouvé(s)`);
|
||||||
|
|
||||||
|
// Si plusieurs avenants, on prend le premier (le plus ancien)
|
||||||
|
// Dans Airtable, il n'y a qu'un seul PDF par contrat
|
||||||
|
const avenant = avenants[0];
|
||||||
|
|
||||||
|
if (avenant.pdf_s3_key) {
|
||||||
|
console.log(` ⏭️ PDF déjà présent (${avenant.pdf_s3_key}) - Ignoré`);
|
||||||
|
stats.success++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Télécharger le PDF depuis Airtable
|
||||||
|
console.log(` 📥 Téléchargement du PDF...`);
|
||||||
|
const pdfBuffer = await downloadFile(row.avenant_pdf_url);
|
||||||
|
console.log(` ✅ PDF téléchargé (${(pdfBuffer.length / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
|
// Générer la clé S3
|
||||||
|
const timestamp = new Date().getTime();
|
||||||
|
const s3Key = `avenants/${row.reference}_avenant_${timestamp}.pdf`;
|
||||||
|
|
||||||
|
// Upload sur S3
|
||||||
|
console.log(` ☁️ Upload vers S3...`);
|
||||||
|
const pdfUrl = await uploadToS3(pdfBuffer, s3Key, 'application/pdf');
|
||||||
|
console.log(` ✅ PDF uploadé: ${s3Key}`);
|
||||||
|
|
||||||
|
// Mettre à jour l'avenant dans Supabase
|
||||||
|
console.log(` 💾 Mise à jour de l'avenant ${avenant.numero_avenant}...`);
|
||||||
|
await updateAvenantWithPdf(avenant.id, s3Key, pdfUrl);
|
||||||
|
console.log(` ✅ Avenant mis à jour`);
|
||||||
|
|
||||||
|
stats.success++;
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(` ❌ Erreur: ${error.message}`);
|
||||||
|
if (error.message.includes('HTTP') || error.message.includes('download')) {
|
||||||
|
stats.downloadError++;
|
||||||
|
} else if (error.message.includes('S3') || error.message.includes('upload')) {
|
||||||
|
stats.uploadError++;
|
||||||
|
} else {
|
||||||
|
stats.updateError++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Délai entre chaque traitement pour éviter de surcharger les APIs
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résumé
|
||||||
|
console.log('\n\n' + '='.repeat(60));
|
||||||
|
console.log('📊 RÉSUMÉ DE LA MIGRATION');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`Total de PDF: ${stats.total}`);
|
||||||
|
console.log(`PDF migrés avec succès: ${stats.success} ✅`);
|
||||||
|
console.log(`Contrats sans avenants: ${stats.noAvenants} ⚠️`);
|
||||||
|
console.log(`Erreurs téléchargement: ${stats.downloadError} ❌`);
|
||||||
|
console.log(`Erreurs upload S3: ${stats.uploadError} ❌`);
|
||||||
|
console.log(`Erreurs mise à jour DB: ${stats.updateError} ❌`);
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
console.log('\n✅ Migration terminée!\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter la migration
|
||||||
|
migrate().catch(console.error);
|
||||||
Loading…
Reference in a new issue