Initial commit
This commit is contained in:
176
utils/formatters.ts
Normal file
176
utils/formatters.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Utilitaires de formatage pour l'application BTP Xpress
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formate un montant en devise avec le symbole Euro
|
||||
* @param amount - Le montant à formater
|
||||
* @param currency - La devise (par défaut EUR)
|
||||
* @returns Le montant formaté avec le symbole de devise
|
||||
*/
|
||||
export const formatCurrency = (amount: number, currency: string = 'EUR'): string => {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate un nombre avec des séparateurs de milliers
|
||||
* @param value - Le nombre à formater
|
||||
* @returns Le nombre formaté avec séparateurs
|
||||
*/
|
||||
export const formatNumber = (value: number): string => {
|
||||
return new Intl.NumberFormat('fr-FR').format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate une date en français
|
||||
* @param date - La date à formater
|
||||
* @param options - Options de formatage
|
||||
* @returns La date formatée
|
||||
*/
|
||||
export const formatDate = (date: string | Date, options?: Intl.DateTimeFormatOptions): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
};
|
||||
|
||||
return new Intl.DateTimeFormat('fr-FR', options || defaultOptions).format(dateObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate une date et heure en français
|
||||
* @param datetime - La date/heure à formater
|
||||
* @returns La date/heure formatée
|
||||
*/
|
||||
export const formatDateTime = (datetime: string | Date): string => {
|
||||
const dateObj = typeof datetime === 'string' ? new Date(datetime) : datetime;
|
||||
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(dateObj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate un pourcentage
|
||||
* @param value - La valeur à formater (entre 0 et 1 ou 0 et 100)
|
||||
* @param isDecimal - Si true, la valeur est entre 0 et 1, sinon entre 0 et 100
|
||||
* @returns Le pourcentage formaté
|
||||
*/
|
||||
export const formatPercentage = (value: number, isDecimal: boolean = false): string => {
|
||||
const percentage = isDecimal ? value * 100 : value;
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}).format(isDecimal ? value : value / 100);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate une durée en jours
|
||||
* @param days - Le nombre de jours
|
||||
* @returns La durée formatée
|
||||
*/
|
||||
export const formatDuration = (days: number): string => {
|
||||
if (days === 0) return 'Aujourd\'hui';
|
||||
if (days === 1) return '1 jour';
|
||||
if (days === -1) return 'Il y a 1 jour';
|
||||
if (days > 0) return `${days} jours`;
|
||||
return `Il y a ${Math.abs(days)} jour${Math.abs(days) > 1 ? 's' : ''}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate la taille d'un fichier
|
||||
* @param bytes - La taille en bytes
|
||||
* @returns La taille formatée
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate un nom complet à partir du prénom et nom
|
||||
* @param prenom - Le prénom
|
||||
* @param nom - Le nom
|
||||
* @returns Le nom complet formaté
|
||||
*/
|
||||
export const formatFullName = (prenom: string, nom: string): string => {
|
||||
return `${prenom} ${nom}`.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate une adresse complète
|
||||
* @param adresse - L'adresse
|
||||
* @param codePostal - Le code postal
|
||||
* @param ville - La ville
|
||||
* @returns L'adresse complète formatée
|
||||
*/
|
||||
export const formatAddress = (adresse?: string, codePostal?: string, ville?: string): string => {
|
||||
const parts = [adresse, codePostal, ville].filter(part => part && part.trim());
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate un numéro de téléphone français
|
||||
* @param telephone - Le numéro de téléphone
|
||||
* @returns Le numéro formaté
|
||||
*/
|
||||
export const formatPhoneNumber = (telephone: string): string => {
|
||||
// Supprime tous les caractères non numériques
|
||||
const cleaned = telephone.replace(/\D/g, '');
|
||||
|
||||
// Vérifie si c'est un numéro français (10 chiffres)
|
||||
if (cleaned.length === 10) {
|
||||
return cleaned.replace(/(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, '$1 $2 $3 $4 $5');
|
||||
}
|
||||
|
||||
// Retourne le numéro tel quel si ce n'est pas un format français standard
|
||||
return telephone;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tronque un texte à une longueur donnée
|
||||
* @param text - Le texte à tronquer
|
||||
* @param maxLength - La longueur maximale
|
||||
* @returns Le texte tronqué
|
||||
*/
|
||||
export const truncateText = (text: string, maxLength: number): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
};
|
||||
|
||||
/**
|
||||
* Capitalise la première lettre d'une chaîne
|
||||
* @param str - La chaîne à capitaliser
|
||||
* @returns La chaîne capitalisée
|
||||
*/
|
||||
export const capitalize = (str: string): string => {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Formate un statut pour l'affichage
|
||||
* @param status - Le statut brut
|
||||
* @returns Le statut formaté
|
||||
*/
|
||||
export const formatStatus = (status: string): string => {
|
||||
return status
|
||||
.split('_')
|
||||
.map(word => capitalize(word))
|
||||
.join(' ');
|
||||
};
|
||||
132
utils/inputSanitizer.ts
Normal file
132
utils/inputSanitizer.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Utilitaires de sanitisation pour les entrées utilisateur côté frontend
|
||||
*/
|
||||
|
||||
export class InputSanitizer {
|
||||
/**
|
||||
* Encode les entités HTML pour prévenir XSS
|
||||
*/
|
||||
static encodeHtml(input: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = input;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime les balises HTML dangereuses
|
||||
*/
|
||||
static stripHtml(input: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
return input.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise un email
|
||||
*/
|
||||
static sanitizeEmail(email: string): string {
|
||||
if (!email) return email;
|
||||
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise un nom/prénom
|
||||
*/
|
||||
static sanitizeName(name: string): string {
|
||||
if (!name) return name;
|
||||
|
||||
// Supprimer les caractères dangereux
|
||||
let sanitized = name.replace(/[<>'"&]/g, '');
|
||||
|
||||
// Garder seulement les lettres, espaces, tirets et apostrophes
|
||||
sanitized = sanitized.replace(/[^a-zA-ZàáâäçéèêëíìîïñóòôöúùûüÿýÀÁÂÄÇÉÈÊËÍÌÎÏÑÓÒÔÖÚÙÛÜŸÝ\s'-]/g, '');
|
||||
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise un numéro de téléphone
|
||||
*/
|
||||
static sanitizePhone(phone: string): string {
|
||||
if (!phone) return phone;
|
||||
|
||||
// Garder seulement les chiffres et le + au début
|
||||
return phone.replace(/[^0-9+]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise une adresse
|
||||
*/
|
||||
static sanitizeAddress(address: string): string {
|
||||
if (!address) return address;
|
||||
|
||||
// Supprimer les caractères dangereux
|
||||
let sanitized = address.replace(/[<>'"&]/g, '');
|
||||
|
||||
// Garder les caractères alphanumériques et ponctuation courante
|
||||
sanitized = sanitized.replace(/[^a-zA-Z0-9àáâäçéèêëíìîïñóòôöúùûüÿýÀÁÂÄÇÉÈÊËÍÌÎÏÑÓÒÔÖÚÙÛÜŸÝ\s,.-]/g, '');
|
||||
|
||||
return sanitized.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide qu'une entrée ne contient pas de code malveillant
|
||||
*/
|
||||
static isInputSafe(input: string): boolean {
|
||||
if (!input) return true;
|
||||
|
||||
const dangerousPatterns = [
|
||||
/<script[^>]*>.*?<\/script>/gi,
|
||||
/javascript:/gi,
|
||||
/vbscript:/gi,
|
||||
/onload=/gi,
|
||||
/onerror=/gi,
|
||||
/onclick=/gi,
|
||||
/onmouseover=/gi,
|
||||
/eval\(/gi,
|
||||
/expression\(/gi,
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(input));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise une entrée générique
|
||||
*/
|
||||
static sanitize(input: string): string {
|
||||
if (!input) return input;
|
||||
|
||||
// Supprimer les balises HTML
|
||||
let sanitized = this.stripHtml(input);
|
||||
|
||||
// Encoder les entités HTML
|
||||
sanitized = this.encodeHtml(sanitized);
|
||||
|
||||
return sanitized.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook React pour la sanitisation automatique des inputs
|
||||
*/
|
||||
export function useSanitizedInput(initialValue: string = '') {
|
||||
const [value, setValue] = React.useState(initialValue);
|
||||
|
||||
const setSanitizedValue = (newValue: string) => {
|
||||
const sanitized = InputSanitizer.sanitize(newValue);
|
||||
setValue(sanitized);
|
||||
};
|
||||
|
||||
return [value, setSanitizedValue] as const;
|
||||
}
|
||||
|
||||
// Import React pour le hook (si disponible)
|
||||
let React: any;
|
||||
try {
|
||||
React = require('react');
|
||||
} catch (e) {
|
||||
// React non disponible, le hook ne sera pas utilisable
|
||||
}
|
||||
150
utils/secureStorage.ts
Normal file
150
utils/secureStorage.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Utilitaire de stockage sécurisé pour BTP Xpress
|
||||
* Gestion des tokens et données sensibles
|
||||
*/
|
||||
|
||||
export interface StorageItem {
|
||||
value: any;
|
||||
timestamp: number;
|
||||
expiry?: number;
|
||||
}
|
||||
|
||||
export class SecureStorage {
|
||||
private static readonly PREFIX = 'btpxpress_';
|
||||
|
||||
/**
|
||||
* Stocke une valeur de manière sécurisée
|
||||
*/
|
||||
static set(key: string, value: any, expiryMinutes?: number): void {
|
||||
try {
|
||||
const item: StorageItem = {
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
expiry: expiryMinutes ? Date.now() + (expiryMinutes * 60 * 1000) : undefined
|
||||
};
|
||||
|
||||
const storageKey = this.PREFIX + key;
|
||||
const encrypted = this.encrypt(JSON.stringify(item));
|
||||
|
||||
// Utiliser sessionStorage par défaut pour plus de sécurité
|
||||
sessionStorage.setItem(storageKey, encrypted);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du stockage sécurisé:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une valeur stockée
|
||||
*/
|
||||
static get(key: string): any {
|
||||
try {
|
||||
const storageKey = this.PREFIX + key;
|
||||
const encrypted = sessionStorage.getItem(storageKey) || localStorage.getItem(storageKey);
|
||||
|
||||
if (!encrypted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decrypted = this.decrypt(encrypted);
|
||||
const item: StorageItem = JSON.parse(decrypted);
|
||||
|
||||
// Vérifier l'expiration
|
||||
if (item.expiry && Date.now() > item.expiry) {
|
||||
this.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération sécurisée:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une valeur stockée
|
||||
*/
|
||||
static remove(key: string): void {
|
||||
try {
|
||||
const storageKey = this.PREFIX + key;
|
||||
sessionStorage.removeItem(storageKey);
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression sécurisée:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide tout le stockage sécurisé
|
||||
*/
|
||||
static clearAll(): void {
|
||||
try {
|
||||
// Nettoyer sessionStorage
|
||||
Object.keys(sessionStorage).forEach(key => {
|
||||
if (key.startsWith(this.PREFIX)) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Nettoyer localStorage
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith(this.PREFIX)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Nettoyer aussi les anciens tokens
|
||||
sessionStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du nettoyage sécurisé:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une clé existe et n'est pas expirée
|
||||
*/
|
||||
static exists(key: string): boolean {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chiffrement simple (Base64 + rotation)
|
||||
* Note: Pour un vrai projet, utiliser une vraie bibliothèque de chiffrement
|
||||
*/
|
||||
private static encrypt(data: string): string {
|
||||
try {
|
||||
// Chiffrement simple avec Base64 et rotation de caractères
|
||||
const rotated = data.split('').map(char =>
|
||||
String.fromCharCode(char.charCodeAt(0) + 3)
|
||||
).join('');
|
||||
|
||||
return btoa(rotated);
|
||||
} catch (error) {
|
||||
console.error('Erreur de chiffrement:', error);
|
||||
return btoa(data); // Fallback vers Base64 simple
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déchiffrement simple
|
||||
*/
|
||||
private static decrypt(encrypted: string): string {
|
||||
try {
|
||||
const decoded = atob(encrypted);
|
||||
|
||||
// Dérotation des caractères
|
||||
return decoded.split('').map(char =>
|
||||
String.fromCharCode(char.charCodeAt(0) - 3)
|
||||
).join('');
|
||||
} catch (error) {
|
||||
console.error('Erreur de déchiffrement:', error);
|
||||
return atob(encrypted); // Fallback vers Base64 simple
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export par défaut pour compatibilité
|
||||
export default SecureStorage;
|
||||
547
utils/workflowTester.ts
Normal file
547
utils/workflowTester.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* Testeur de workflows pour valider tous les processus métier
|
||||
* Simule les interactions utilisateur réelles avec le système
|
||||
*/
|
||||
|
||||
import testDataService from '../services/testDataService';
|
||||
import phaseValidationService from '../services/phaseValidationService';
|
||||
import chantierTemplateService from '../services/chantierTemplateService';
|
||||
import type { PhaseChantier, Chantier, Client } from '../types/btp';
|
||||
import type { TypeChantier } from '../types/chantier-templates';
|
||||
|
||||
export interface WorkflowTestResult {
|
||||
workflowName: string;
|
||||
success: boolean;
|
||||
steps: WorkflowStep[];
|
||||
duration: number;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
stepName: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
class WorkflowTester {
|
||||
|
||||
/**
|
||||
* Test du workflow complet de création d'un projet
|
||||
*/
|
||||
async testProjectCreationWorkflow(): Promise<WorkflowTestResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
try {
|
||||
// Étape 1: Création du client
|
||||
steps.push({
|
||||
stepName: 'Création client',
|
||||
success: true,
|
||||
message: 'Client créé avec succès',
|
||||
data: testDataService.generateTestClient(1)
|
||||
});
|
||||
const client = steps[0].data as Client;
|
||||
|
||||
// Étape 2: Sélection du type de chantier
|
||||
steps.push({
|
||||
stepName: 'Sélection type chantier',
|
||||
success: true,
|
||||
message: 'Type MAISON_INDIVIDUELLE sélectionné',
|
||||
data: 'MAISON_INDIVIDUELLE'
|
||||
});
|
||||
const typeChantier = steps[1].data as TypeChantier;
|
||||
|
||||
// Étape 3: Prévisualisation des phases (template)
|
||||
const template = chantierTemplateService.getTemplate(typeChantier);
|
||||
steps.push({
|
||||
stepName: 'Prévisualisation phases',
|
||||
success: template.phases.length > 0,
|
||||
message: `${template.phases.length} phases prévues pour ce type`,
|
||||
data: template
|
||||
});
|
||||
|
||||
// Étape 4: Création du chantier
|
||||
const chantier = testDataService.generateTestChantier(1, typeChantier, client);
|
||||
steps.push({
|
||||
stepName: 'Création chantier',
|
||||
success: !!chantier.id && !!chantier.typeChantier,
|
||||
message: `Chantier "${chantier.nom}" créé`,
|
||||
data: chantier
|
||||
});
|
||||
|
||||
// Étape 5: Génération automatique des phases
|
||||
const phases = testDataService.generateTestPhases(chantier);
|
||||
steps.push({
|
||||
stepName: 'Génération phases',
|
||||
success: phases.length > 0,
|
||||
message: `${phases.length} phases générées automatiquement`,
|
||||
data: phases
|
||||
});
|
||||
|
||||
// Étape 6: Validation de la cohérence
|
||||
const validation = phaseValidationService.validateProjectSchedule(phases);
|
||||
steps.push({
|
||||
stepName: 'Validation cohérence',
|
||||
success: validation.globalErrors.length === 0,
|
||||
message: validation.isValid ? 'Planning cohérent' : `${validation.globalErrors.length} erreurs détectées`,
|
||||
data: validation
|
||||
});
|
||||
|
||||
return {
|
||||
workflowName: 'Création de projet complet',
|
||||
success: steps.every(s => s.success),
|
||||
steps,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowName: 'Création de projet complet',
|
||||
success: false,
|
||||
steps,
|
||||
duration: Date.now() - startTime,
|
||||
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test du workflow de filtrage avancé
|
||||
*/
|
||||
async testAdvancedFilteringWorkflow(): Promise<WorkflowTestResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
try {
|
||||
// Génération de données de test variées
|
||||
const dataset = testDataService.generateTestDataset(5);
|
||||
const allPhases = dataset.flatMap(d => d.phases);
|
||||
|
||||
steps.push({
|
||||
stepName: 'Génération données test',
|
||||
success: allPhases.length > 0,
|
||||
message: `${allPhases.length} phases générées pour ${dataset.length} projets`,
|
||||
data: { dataset, allPhases }
|
||||
});
|
||||
|
||||
// Test filtre par statut
|
||||
const filtres = {
|
||||
enCours: allPhases.filter(p => p.statut === 'EN_COURS'),
|
||||
terminees: allPhases.filter(p => p.statut === 'TERMINEE'),
|
||||
planifiees: allPhases.filter(p => p.statut === 'PLANIFIEE'),
|
||||
critiques: allPhases.filter(p => p.critique),
|
||||
sousPhases: allPhases.filter(p => p.phaseParent),
|
||||
principales: allPhases.filter(p => !p.phaseParent)
|
||||
};
|
||||
|
||||
steps.push({
|
||||
stepName: 'Filtres par statut',
|
||||
success: true,
|
||||
message: `Filtres appliqués: ${filtres.enCours.length} en cours, ${filtres.terminees.length} terminées, ${filtres.planifiees.length} planifiées`,
|
||||
data: filtres
|
||||
});
|
||||
|
||||
// Test filtre hiérarchique
|
||||
const hierarchie = {
|
||||
principales: filtres.principales.length,
|
||||
sousPhases: filtres.sousPhases.length,
|
||||
total: allPhases.length
|
||||
};
|
||||
|
||||
steps.push({
|
||||
stepName: 'Filtre hiérarchique',
|
||||
success: hierarchie.principales + hierarchie.sousPhases === hierarchie.total,
|
||||
message: `Hiérarchie: ${hierarchie.principales} principales + ${hierarchie.sousPhases} sous-phases = ${hierarchie.total} total`,
|
||||
data: hierarchie
|
||||
});
|
||||
|
||||
// Test filtre par criticité
|
||||
steps.push({
|
||||
stepName: 'Filtre par criticité',
|
||||
success: true,
|
||||
message: `${filtres.critiques.length} phases critiques identifiées`,
|
||||
data: filtres.critiques
|
||||
});
|
||||
|
||||
// Test filtre par dates (simulation)
|
||||
const aujourd_hui = new Date();
|
||||
const phasesActuelles = allPhases.filter(p => {
|
||||
if (!p.dateDebutPrevue || !p.dateFinPrevue) return false;
|
||||
const debut = new Date(p.dateDebutPrevue);
|
||||
const fin = new Date(p.dateFinPrevue);
|
||||
return debut <= aujourd_hui && fin >= aujourd_hui;
|
||||
});
|
||||
|
||||
steps.push({
|
||||
stepName: 'Filtre par période',
|
||||
success: true,
|
||||
message: `${phasesActuelles.length} phases dans la période actuelle`,
|
||||
data: phasesActuelles
|
||||
});
|
||||
|
||||
return {
|
||||
workflowName: 'Filtrage avancé',
|
||||
success: steps.every(s => s.success),
|
||||
steps,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowName: 'Filtrage avancé',
|
||||
success: false,
|
||||
steps,
|
||||
duration: Date.now() - startTime,
|
||||
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test du workflow de validation et démarrage de phases
|
||||
*/
|
||||
async testPhaseValidationWorkflow(): Promise<WorkflowTestResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
try {
|
||||
// Génération d'un projet avec prérequis
|
||||
const client = testDataService.generateTestClient(1);
|
||||
const chantier = testDataService.generateTestChantier(1, 'MAISON_INDIVIDUELLE', client);
|
||||
const phases = testDataService.generatePhasesWithPrerequisites(chantier);
|
||||
|
||||
steps.push({
|
||||
stepName: 'Génération projet avec prérequis',
|
||||
success: phases.length > 0,
|
||||
message: `Projet avec ${phases.length} phases et prérequis généré`,
|
||||
data: { chantier, phases }
|
||||
});
|
||||
|
||||
// Test validation première phase (sans prérequis)
|
||||
const premierePhase = phases.find(p => !p.prerequis || p.prerequis.length === 0);
|
||||
if (!premierePhase) {
|
||||
throw new Error('Aucune phase sans prérequis trouvée');
|
||||
}
|
||||
|
||||
const validationPremiere = phaseValidationService.validatePhaseStart(premierePhase, phases);
|
||||
steps.push({
|
||||
stepName: 'Validation première phase',
|
||||
success: validationPremiere.canStart,
|
||||
message: `Première phase "${premierePhase.nom}" peut ${validationPremiere.canStart ? '' : 'ne pas '}démarrer`,
|
||||
data: validationPremiere
|
||||
});
|
||||
|
||||
// Simulation démarrage première phase
|
||||
premierePhase.statut = 'EN_COURS';
|
||||
premierePhase.dateDebutReelle = new Date().toISOString().split('T')[0];
|
||||
premierePhase.pourcentageAvancement = 30;
|
||||
|
||||
steps.push({
|
||||
stepName: 'Démarrage première phase',
|
||||
success: premierePhase.statut === 'EN_COURS',
|
||||
message: `Phase "${premierePhase.nom}" démarrée avec succès`,
|
||||
data: premierePhase
|
||||
});
|
||||
|
||||
// Test validation phase avec prérequis
|
||||
const phaseAvecPrerequis = phases.find(p => p.prerequis && p.prerequis.length > 0);
|
||||
if (phaseAvecPrerequis) {
|
||||
const validationPrerequis = phaseValidationService.validatePhaseStart(phaseAvecPrerequis, phases);
|
||||
const prerequis = phaseAvecPrerequis.prerequis!;
|
||||
const prerequisTermines = prerequis.every(prereqId => {
|
||||
const prereq = phases.find(p => p.id === prereqId);
|
||||
return prereq && prereq.statut === 'TERMINEE';
|
||||
});
|
||||
|
||||
steps.push({
|
||||
stepName: 'Validation phase avec prérequis',
|
||||
success: prerequisTermines || !validationPrerequis.canStart,
|
||||
message: `Phase "${phaseAvecPrerequis.nom}" - ${prerequis.length} prérequis, peut ${validationPrerequis.canStart ? '' : 'ne pas '}démarrer`,
|
||||
data: validationPrerequis
|
||||
});
|
||||
}
|
||||
|
||||
// Test termination et cascade
|
||||
premierePhase.statut = 'TERMINEE';
|
||||
premierePhase.dateFinReelle = new Date().toISOString().split('T')[0];
|
||||
premierePhase.pourcentageAvancement = 100;
|
||||
|
||||
// Revalider les phases suivantes
|
||||
const phasesBloquees = phases.filter(p =>
|
||||
p.prerequis?.includes(premierePhase.id!) && p.statut === 'PLANIFIEE'
|
||||
);
|
||||
|
||||
const validationsApres = phasesBloquees.map(p => ({
|
||||
phase: p.nom,
|
||||
validation: phaseValidationService.validatePhaseStart(p, phases)
|
||||
}));
|
||||
|
||||
steps.push({
|
||||
stepName: 'Cascade de déverrouillage',
|
||||
success: true,
|
||||
message: `${phasesBloquees.length} phases déverrouillées après fin de "${premierePhase.nom}"`,
|
||||
data: validationsApres
|
||||
});
|
||||
|
||||
return {
|
||||
workflowName: 'Validation et démarrage phases',
|
||||
success: steps.every(s => s.success),
|
||||
steps,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowName: 'Validation et démarrage phases',
|
||||
success: false,
|
||||
steps,
|
||||
duration: Date.now() - startTime,
|
||||
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test du workflow de gestion hiérarchique
|
||||
*/
|
||||
async testHierarchicalManagementWorkflow(): Promise<WorkflowTestResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
try {
|
||||
// Génération d'un projet avec phases et sous-phases
|
||||
const client = testDataService.generateTestClient(1);
|
||||
const chantier = testDataService.generateTestChantier(1, 'MAISON_INDIVIDUELLE', client);
|
||||
const phases = testDataService.generateTestPhases(chantier);
|
||||
|
||||
const phasesPrincipales = phases.filter(p => !p.phaseParent);
|
||||
const sousPhases = phases.filter(p => p.phaseParent);
|
||||
|
||||
steps.push({
|
||||
stepName: 'Génération structure hiérarchique',
|
||||
success: phasesPrincipales.length > 0 && sousPhases.length > 0,
|
||||
message: `Structure: ${phasesPrincipales.length} phases principales, ${sousPhases.length} sous-phases`,
|
||||
data: { phasesPrincipales, sousPhases }
|
||||
});
|
||||
|
||||
// Vérification des liens parent-enfant
|
||||
let liensCorrects = 0;
|
||||
let liensIncorrects = 0;
|
||||
|
||||
sousPhases.forEach(sousPhase => {
|
||||
const parent = phases.find(p => p.id === sousPhase.phaseParent);
|
||||
if (parent) {
|
||||
liensCorrects++;
|
||||
} else {
|
||||
liensIncorrects++;
|
||||
}
|
||||
});
|
||||
|
||||
steps.push({
|
||||
stepName: 'Vérification liens hiérarchiques',
|
||||
success: liensIncorrects === 0,
|
||||
message: `${liensCorrects} liens corrects, ${liensIncorrects} liens incorrects`,
|
||||
data: { liensCorrects, liensIncorrects }
|
||||
});
|
||||
|
||||
// Test d'affichage hiérarchique (simulation)
|
||||
const affichageHierarchique = phasesPrincipales.map(principale => ({
|
||||
phase: principale,
|
||||
sousPhases: sousPhases.filter(sp => sp.phaseParent === principale.id),
|
||||
niveau: 0
|
||||
}));
|
||||
|
||||
const totalElements = affichageHierarchique.reduce((acc, item) =>
|
||||
acc + 1 + item.sousPhases.length, 0
|
||||
);
|
||||
|
||||
steps.push({
|
||||
stepName: 'Simulation affichage hiérarchique',
|
||||
success: totalElements === phases.length,
|
||||
message: `Affichage hiérarchique: ${totalElements} éléments organisés`,
|
||||
data: affichageHierarchique
|
||||
});
|
||||
|
||||
// Test de filtrage hiérarchique
|
||||
const filtreSeulementPrincipales = phases.filter(p => !p.phaseParent);
|
||||
const filtreAvecSousPhases = phases; // Tous
|
||||
|
||||
steps.push({
|
||||
stepName: 'Filtrage hiérarchique',
|
||||
success: filtreSeulementPrincipales.length < filtreAvecSousPhases.length,
|
||||
message: `Filtres: ${filtreSeulementPrincipales.length} principales uniquement, ${filtreAvecSousPhases.length} avec sous-phases`,
|
||||
data: { filtreSeulementPrincipales, filtreAvecSousPhases }
|
||||
});
|
||||
|
||||
return {
|
||||
workflowName: 'Gestion hiérarchique',
|
||||
success: steps.every(s => s.success),
|
||||
steps,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowName: 'Gestion hiérarchique',
|
||||
success: false,
|
||||
steps,
|
||||
duration: Date.now() - startTime,
|
||||
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test du workflow de templates et auto-génération
|
||||
*/
|
||||
async testTemplateAutoGenerationWorkflow(): Promise<WorkflowTestResult> {
|
||||
const startTime = Date.now();
|
||||
const steps: WorkflowStep[] = [];
|
||||
|
||||
try {
|
||||
// Test de tous les types de chantier disponibles
|
||||
const typesDisponibles = chantierTemplateService.getAvailableTypes();
|
||||
|
||||
steps.push({
|
||||
stepName: 'Récupération types chantier',
|
||||
success: typesDisponibles.length > 0,
|
||||
message: `${typesDisponibles.length} types de chantier disponibles`,
|
||||
data: typesDisponibles
|
||||
});
|
||||
|
||||
// Génération pour chaque type
|
||||
const generationsParType: any[] = [];
|
||||
|
||||
for (const type of typesDisponibles.slice(0, 3)) { // Limiter à 3 pour les tests
|
||||
const client = testDataService.generateTestClient(1);
|
||||
const chantier = testDataService.generateTestChantier(1, type.id as TypeChantier, client);
|
||||
const phases = testDataService.generateTestPhases(chantier);
|
||||
|
||||
generationsParType.push({
|
||||
type: type.id,
|
||||
nom: type.nom,
|
||||
phases: phases.length,
|
||||
sousPhases: phases.filter(p => p.phaseParent).length,
|
||||
dureeEstimee: phases.reduce((acc, p) => acc + (p.dureeEstimeeHeures || 0), 0)
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
stepName: 'Auto-génération par type',
|
||||
success: generationsParType.every(g => g.phases > 0),
|
||||
message: `Génération réussie pour ${generationsParType.length} types`,
|
||||
data: generationsParType
|
||||
});
|
||||
|
||||
// Comparaison avec templates
|
||||
const comparaisonTemplates = generationsParType.map(gen => {
|
||||
const template = chantierTemplateService.getTemplate(gen.type as TypeChantier);
|
||||
return {
|
||||
type: gen.type,
|
||||
templatePhases: template.phases.length,
|
||||
generatedPhases: gen.phases,
|
||||
match: template.phases.length <= gen.phases // Généré peut inclure sous-phases
|
||||
};
|
||||
});
|
||||
|
||||
steps.push({
|
||||
stepName: 'Comparaison avec templates',
|
||||
success: comparaisonTemplates.every(c => c.match),
|
||||
message: `Correspondance templates: ${comparaisonTemplates.filter(c => c.match).length}/${comparaisonTemplates.length}`,
|
||||
data: comparaisonTemplates
|
||||
});
|
||||
|
||||
// Test cohérence des prérequis générés
|
||||
const client = testDataService.generateTestClient(1);
|
||||
const chantier = testDataService.generateTestChantier(1, 'MAISON_INDIVIDUELLE', client);
|
||||
const phasesAvecPrerequis = testDataService.generatePhasesWithPrerequisites(chantier);
|
||||
|
||||
const prerequisValides = phasesAvecPrerequis.every(phase => {
|
||||
if (!phase.prerequis) return true;
|
||||
return phase.prerequis.every(prereqId =>
|
||||
phasesAvecPrerequis.some(p => p.id === prereqId)
|
||||
);
|
||||
});
|
||||
|
||||
steps.push({
|
||||
stepName: 'Cohérence prérequis',
|
||||
success: prerequisValides,
|
||||
message: `Prérequis ${prerequisValides ? 'cohérents' : 'incohérents'} pour ${phasesAvecPrerequis.length} phases`,
|
||||
data: { phasesAvecPrerequis, prerequisValides }
|
||||
});
|
||||
|
||||
return {
|
||||
workflowName: 'Templates et auto-génération',
|
||||
success: steps.every(s => s.success),
|
||||
steps,
|
||||
duration: Date.now() - startTime
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
workflowName: 'Templates et auto-génération',
|
||||
success: false,
|
||||
steps,
|
||||
duration: Date.now() - startTime,
|
||||
errorMessage: error instanceof Error ? error.message : 'Erreur inconnue'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exécute tous les workflows de test
|
||||
*/
|
||||
async runAllWorkflows(): Promise<WorkflowTestResult[]> {
|
||||
const results = await Promise.all([
|
||||
this.testProjectCreationWorkflow(),
|
||||
this.testAdvancedFilteringWorkflow(),
|
||||
this.testPhaseValidationWorkflow(),
|
||||
this.testHierarchicalManagementWorkflow(),
|
||||
this.testTemplateAutoGenerationWorkflow()
|
||||
]);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un rapport de workflows
|
||||
*/
|
||||
generateWorkflowReport(results: WorkflowTestResult[]): string {
|
||||
const totalWorkflows = results.length;
|
||||
const successfulWorkflows = results.filter(r => r.success).length;
|
||||
const totalSteps = results.reduce((acc, r) => acc + r.steps.length, 0);
|
||||
const successfulSteps = results.reduce((acc, r) => acc + r.steps.filter(s => s.success).length, 0);
|
||||
const totalDuration = results.reduce((acc, r) => acc + r.duration, 0);
|
||||
|
||||
let report = `# Rapport des workflows BTPXpress\n\n`;
|
||||
report += `## Résumé global\n`;
|
||||
report += `- Workflows testés: ${totalWorkflows}\n`;
|
||||
report += `- Workflows réussis: ${successfulWorkflows} (${Math.round((successfulWorkflows/totalWorkflows)*100)}%)\n`;
|
||||
report += `- Étapes totales: ${totalSteps}\n`;
|
||||
report += `- Étapes réussies: ${successfulSteps} (${Math.round((successfulSteps/totalSteps)*100)}%)\n`;
|
||||
report += `- Durée totale: ${totalDuration}ms\n\n`;
|
||||
|
||||
results.forEach(result => {
|
||||
report += `## ${result.workflowName}\n`;
|
||||
report += `**Statut:** ${result.success ? '✅ Réussi' : '❌ Échoué'}\n`;
|
||||
report += `**Durée:** ${result.duration}ms\n`;
|
||||
|
||||
if (result.errorMessage) {
|
||||
report += `**Erreur:** ${result.errorMessage}\n`;
|
||||
}
|
||||
|
||||
report += `**Étapes:**\n`;
|
||||
result.steps.forEach((step, index) => {
|
||||
const status = step.success ? '✅' : '❌';
|
||||
report += `${index + 1}. ${status} ${step.stepName}: ${step.message}\n`;
|
||||
});
|
||||
|
||||
report += `\n`;
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
export default new WorkflowTester();
|
||||
Reference in New Issue
Block a user