310 lines
9.7 KiB
TypeScript
310 lines
9.7 KiB
TypeScript
import * as XLSX from 'xlsx';
|
|
import { getDb } from './db';
|
|
import { formations as formationsTable, sequences as sequencesTable, datesFormation } from '../drizzle/schema';
|
|
|
|
export interface FormationImport {
|
|
nom: string;
|
|
description: string;
|
|
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
|
|
}
|
|
|
|
export interface SequenceImport {
|
|
formationNom: string;
|
|
nom: string;
|
|
lieu: string;
|
|
publicCible: 'directeur' | 'chef_service' | 'tous' | 'autre';
|
|
capaciteMax: number;
|
|
dateBlocage: string;
|
|
statut: 'ouverte' | 'bloquee' | 'terminee';
|
|
dates: Array<{ debut: string; fin: string }>;
|
|
}
|
|
|
|
export interface ImportResult {
|
|
success: boolean;
|
|
formationsCreated: number;
|
|
sequencesCreated: number;
|
|
errors: string[];
|
|
warnings: string[];
|
|
}
|
|
|
|
/**
|
|
* Parse un fichier Excel et retourne les données structurées
|
|
*/
|
|
export function parseExcelFile(buffer: Buffer): { formations: FormationImport[]; sequences: SequenceImport[] } {
|
|
const workbook = XLSX.read(buffer, { type: 'buffer' });
|
|
|
|
const formations: FormationImport[] = [];
|
|
const sequences: SequenceImport[] = [];
|
|
|
|
// Parser la feuille "Formations"
|
|
if (workbook.SheetNames.includes('Formations')) {
|
|
const sheet = workbook.Sheets['Formations'];
|
|
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
|
|
|
|
// Ignorer la première ligne (en-têtes) et les lignes vides
|
|
for (let i = 1; i < data.length; i++) {
|
|
const row = data[i];
|
|
if (!row || row.length === 0 || !row[0]) continue;
|
|
|
|
// Arrêter si on atteint les instructions
|
|
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
|
|
|
|
formations.push({
|
|
nom: String(row[0] || '').trim(),
|
|
description: String(row[1] || '').trim(),
|
|
publicCible: normalizePublicCible(String(row[2] || '').trim()),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Parser la feuille "Séquences"
|
|
if (workbook.SheetNames.includes('Séquences')) {
|
|
const sheet = workbook.Sheets['Séquences'];
|
|
const data = XLSX.utils.sheet_to_json<any>(sheet, { header: 1 });
|
|
|
|
for (let i = 1; i < data.length; i++) {
|
|
const row = data[i];
|
|
if (!row || row.length === 0 || !row[0]) continue;
|
|
|
|
// Arrêter si on atteint les instructions
|
|
if (String(row[0]).toUpperCase().includes('INSTRUCTIONS')) break;
|
|
|
|
const dates: Array<{ debut: string; fin: string }> = [];
|
|
|
|
// Parser les 4 dates possibles (colonnes 7-14)
|
|
for (let d = 0; d < 4; d++) {
|
|
const debutIdx = 7 + (d * 2);
|
|
const finIdx = 8 + (d * 2);
|
|
|
|
if (row[debutIdx] && row[finIdx]) {
|
|
dates.push({
|
|
debut: parseExcelDate(row[debutIdx]),
|
|
fin: parseExcelDate(row[finIdx]),
|
|
});
|
|
}
|
|
}
|
|
|
|
sequences.push({
|
|
formationNom: String(row[0] || '').trim(),
|
|
nom: String(row[1] || '').trim(),
|
|
lieu: String(row[2] || '').trim(),
|
|
publicCible: normalizePublicCible(String(row[3] || '').trim()),
|
|
capaciteMax: parseInt(String(row[4] || '12')),
|
|
dateBlocage: parseExcelDate(row[5]),
|
|
statut: normalizeStatut(String(row[6] || '').trim()),
|
|
dates,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { formations, sequences };
|
|
}
|
|
|
|
/**
|
|
* Valide les données importées
|
|
*/
|
|
export function validateImportData(
|
|
formations: FormationImport[],
|
|
sequences: SequenceImport[]
|
|
): { valid: boolean; errors: string[]; warnings: string[] } {
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
|
|
// Valider les formations
|
|
formations.forEach((formation, index) => {
|
|
if (!formation.nom) {
|
|
errors.push(`Formation ligne ${index + 2}: Le nom est obligatoire`);
|
|
}
|
|
if (!formation.description) {
|
|
errors.push(`Formation ligne ${index + 2}: La description est obligatoire`);
|
|
}
|
|
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(formation.publicCible)) {
|
|
errors.push(`Formation ligne ${index + 2}: Public cible invalide (${formation.publicCible})`);
|
|
}
|
|
});
|
|
|
|
// Créer un set des noms de formations pour validation des séquences
|
|
const formationNames = new Set(formations.map(f => f.nom.toLowerCase()));
|
|
|
|
// Valider les séquences
|
|
sequences.forEach((sequence, index) => {
|
|
if (!sequence.formationNom) {
|
|
errors.push(`Séquence ligne ${index + 2}: Le nom de la formation est obligatoire`);
|
|
} else if (!formationNames.has(sequence.formationNom.toLowerCase())) {
|
|
errors.push(`Séquence ligne ${index + 2}: Formation "${sequence.formationNom}" non trouvée dans la feuille Formations`);
|
|
}
|
|
|
|
if (!sequence.nom) {
|
|
errors.push(`Séquence ligne ${index + 2}: Le nom est obligatoire`);
|
|
}
|
|
if (!sequence.lieu) {
|
|
errors.push(`Séquence ligne ${index + 2}: Le lieu est obligatoire`);
|
|
}
|
|
if (!['directeur', 'chef_service', 'tous', 'autre'].includes(sequence.publicCible)) {
|
|
errors.push(`Séquence ligne ${index + 2}: Public cible invalide`);
|
|
}
|
|
if (!sequence.dateBlocage) {
|
|
errors.push(`Séquence ligne ${index + 2}: La date de blocage est obligatoire`);
|
|
}
|
|
if (!['ouverte', 'bloquee', 'terminee'].includes(sequence.statut)) {
|
|
errors.push(`Séquence ligne ${index + 2}: Statut invalide`);
|
|
}
|
|
if (sequence.dates.length === 0) {
|
|
errors.push(`Séquence ligne ${index + 2}: Au moins une date est obligatoire`);
|
|
}
|
|
if (sequence.dates.length > 4) {
|
|
warnings.push(`Séquence ligne ${index + 2}: Maximum 4 dates autorisées, les dates supplémentaires seront ignorées`);
|
|
}
|
|
});
|
|
|
|
return {
|
|
valid: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Importe les données dans la base de données
|
|
*/
|
|
export async function importToDatabase(
|
|
formations: FormationImport[],
|
|
sequences: SequenceImport[]
|
|
): Promise<ImportResult> {
|
|
const db = await getDb();
|
|
if (!db) {
|
|
return {
|
|
success: false,
|
|
formationsCreated: 0,
|
|
sequencesCreated: 0,
|
|
errors: ['Connexion à la base de données impossible'],
|
|
warnings: [],
|
|
};
|
|
}
|
|
|
|
const errors: string[] = [];
|
|
const warnings: string[] = [];
|
|
let formationsCreated = 0;
|
|
let sequencesCreated = 0;
|
|
|
|
try {
|
|
// Map pour stocker les IDs des formations créées
|
|
const formationIdMap = new Map<string, number>();
|
|
|
|
// Importer les formations
|
|
for (const formation of formations) {
|
|
try {
|
|
const result = await db.insert(formationsTable).values({
|
|
nom: formation.nom,
|
|
description: formation.description,
|
|
publicCible: formation.publicCible,
|
|
lienInscription: generateUniqueLien(),
|
|
});
|
|
|
|
const formationId = Number(result.insertId);
|
|
formationIdMap.set(formation.nom.toLowerCase(), formationId);
|
|
formationsCreated++;
|
|
} catch (error: any) {
|
|
errors.push(`Erreur lors de la création de la formation "${formation.nom}": ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Importer les séquences
|
|
for (const sequence of sequences) {
|
|
try {
|
|
const formationId = formationIdMap.get(sequence.formationNom.toLowerCase());
|
|
if (!formationId) {
|
|
errors.push(`Formation "${sequence.formationNom}" non trouvée pour la séquence "${sequence.nom}"`);
|
|
continue;
|
|
}
|
|
|
|
const result = await db.insert(sequencesTable).values({
|
|
formationId,
|
|
nom: sequence.nom,
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
capaciteMax: sequence.capaciteMax,
|
|
dateBlocage: new Date(sequence.dateBlocage),
|
|
statut: sequence.statut,
|
|
});
|
|
|
|
const sequenceId = Number(result.insertId);
|
|
|
|
// Insérer les dates de formation
|
|
for (const date of sequence.dates.slice(0, 4)) {
|
|
await db.insert(datesFormation).values({
|
|
sequenceId,
|
|
dateDebut: new Date(date.debut),
|
|
dateFin: new Date(date.fin),
|
|
});
|
|
}
|
|
|
|
sequencesCreated++;
|
|
} catch (error: any) {
|
|
errors.push(`Erreur lors de la création de la séquence "${sequence.nom}": ${error.message}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: errors.length === 0,
|
|
formationsCreated,
|
|
sequencesCreated,
|
|
errors,
|
|
warnings,
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
formationsCreated,
|
|
sequencesCreated,
|
|
errors: [`Erreur générale: ${error.message}`],
|
|
warnings,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalise le public cible
|
|
*/
|
|
function normalizePublicCible(value: string): 'directeur' | 'chef_service' | 'tous' | 'autre' {
|
|
const normalized = value.toLowerCase().trim();
|
|
if (normalized === 'directeur') return 'directeur';
|
|
if (normalized === 'chef_service' || normalized === 'chef de service') return 'chef_service';
|
|
if (normalized === 'tous') return 'tous';
|
|
return 'autre';
|
|
}
|
|
|
|
/**
|
|
* Normalise le statut
|
|
*/
|
|
function normalizeStatut(value: string): 'ouverte' | 'bloquee' | 'terminee' {
|
|
const normalized = value.toLowerCase().trim();
|
|
if (normalized === 'ouverte') return 'ouverte';
|
|
if (normalized === 'bloquee' || normalized === 'bloquée') return 'bloquee';
|
|
if (normalized === 'terminee' || normalized === 'terminée') return 'terminee';
|
|
return 'ouverte';
|
|
}
|
|
|
|
/**
|
|
* Parse une date Excel (peut être un nombre de série Excel ou une chaîne)
|
|
*/
|
|
function parseExcelDate(value: any): string {
|
|
if (!value) return '';
|
|
|
|
// Si c'est un nombre (date Excel)
|
|
if (typeof value === 'number') {
|
|
const date = XLSX.SSF.parse_date_code(value);
|
|
return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')} ${String(date.H || 0).padStart(2, '0')}:${String(date.M || 0).padStart(2, '0')}`;
|
|
}
|
|
|
|
// Si c'est déjà une chaîne
|
|
return String(value).trim();
|
|
}
|
|
|
|
/**
|
|
* Génère un lien unique pour une formation
|
|
*/
|
|
function generateUniqueLien(): string {
|
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
|
}
|