570 lines
21 KiB
TypeScript
Executable File
570 lines
21 KiB
TypeScript
Executable File
/**
|
|
* Service de validation des prérequis de phases
|
|
* Gère la validation des dépendances entre phases et leurs prérequis métier
|
|
*/
|
|
|
|
import type { PhaseChantier } from '../types/btp';
|
|
|
|
export interface PhaseValidationResult {
|
|
canStart: boolean;
|
|
errors: ValidationError[];
|
|
warnings: ValidationWarning[];
|
|
blockedBy: string[];
|
|
readyToStart: boolean;
|
|
}
|
|
|
|
export interface ValidationError {
|
|
code: string;
|
|
message: string;
|
|
severity: 'error' | 'warning' | 'info';
|
|
phaseId?: string;
|
|
prerequisiteId?: string;
|
|
}
|
|
|
|
export interface ValidationWarning {
|
|
code: string;
|
|
message: string;
|
|
recommendation?: string;
|
|
}
|
|
|
|
export interface PrerequisiteStatus {
|
|
prerequisiteId: string;
|
|
prerequisiteName: string;
|
|
status: 'completed' | 'in_progress' | 'not_started' | 'not_found';
|
|
completionDate?: Date;
|
|
isBlocking: boolean;
|
|
}
|
|
|
|
class PhaseValidationService {
|
|
/**
|
|
* Valide si une phase peut être démarrée en fonction de ses prérequis
|
|
*/
|
|
validatePhaseStart(
|
|
phase: PhaseChantier,
|
|
allPhases: PhaseChantier[],
|
|
options: { strictMode?: boolean } = {}
|
|
): PhaseValidationResult {
|
|
const { strictMode = false } = options;
|
|
const result: PhaseValidationResult = {
|
|
canStart: true,
|
|
errors: [],
|
|
warnings: [],
|
|
blockedBy: [],
|
|
readyToStart: false
|
|
};
|
|
|
|
// Vérifier les prérequis de base
|
|
this.validateBasicPrerequisites(phase, result);
|
|
|
|
// Vérifier les dépendances entre phases
|
|
this.validatePhaseDependencies(phase, allPhases, result, strictMode);
|
|
|
|
// Vérifier les prérequis métier
|
|
this.validateBusinessPrerequisites(phase, allPhases, result);
|
|
|
|
// Vérifier les contraintes temporelles
|
|
this.validateTemporalConstraints(phase, allPhases, result);
|
|
|
|
// Déterminer si la phase est prête à démarrer
|
|
result.readyToStart = result.canStart && result.errors.length === 0;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Valide les prérequis de base d'une phase
|
|
*/
|
|
private validateBasicPrerequisites(phase: PhaseChantier, result: PhaseValidationResult): void {
|
|
// Vérifier le statut de la phase
|
|
if (phase.statut === 'TERMINEE') {
|
|
result.errors.push({
|
|
code: 'PHASE_ALREADY_COMPLETED',
|
|
message: 'Cette phase est déjà terminée',
|
|
severity: 'info'
|
|
});
|
|
result.canStart = false;
|
|
}
|
|
|
|
if (phase.statut === 'EN_COURS') {
|
|
result.warnings.push({
|
|
code: 'PHASE_ALREADY_STARTED',
|
|
message: 'Cette phase est déjà en cours',
|
|
recommendation: 'Vérifiez l\'avancement actuel'
|
|
});
|
|
}
|
|
|
|
// Vérifier les dates
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
if (phase.dateDebutPrevue) {
|
|
const dateDebut = new Date(phase.dateDebutPrevue);
|
|
dateDebut.setHours(0, 0, 0, 0);
|
|
|
|
if (dateDebut > today) {
|
|
result.warnings.push({
|
|
code: 'PHASE_FUTURE_START',
|
|
message: `Cette phase est prévue pour commencer le ${dateDebut.toLocaleDateString('fr-FR')}`,
|
|
recommendation: 'Vérifiez si un démarrage anticipé est possible'
|
|
});
|
|
}
|
|
}
|
|
|
|
if (phase.dateFinPrevue) {
|
|
const dateFin = new Date(phase.dateFinPrevue);
|
|
dateFin.setHours(0, 0, 0, 0);
|
|
|
|
if (dateFin < today) {
|
|
result.warnings.push({
|
|
code: 'PHASE_OVERDUE',
|
|
message: 'Cette phase aurait dû être terminée',
|
|
recommendation: 'Réviser la planification'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valide les dépendances entre phases
|
|
*/
|
|
private validatePhaseDependencies(
|
|
phase: PhaseChantier,
|
|
allPhases: PhaseChantier[],
|
|
result: PhaseValidationResult,
|
|
strictMode: boolean
|
|
): void {
|
|
if (!phase.prerequis || phase.prerequis.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const prerequisiteStatuses = this.getPrerequisiteStatuses(phase, allPhases);
|
|
|
|
prerequisiteStatuses.forEach(prereq => {
|
|
switch (prereq.status) {
|
|
case 'not_found':
|
|
result.errors.push({
|
|
code: 'PREREQUISITE_NOT_FOUND',
|
|
message: `Prérequis non trouvé: ${prereq.prerequisiteName}`,
|
|
severity: 'error',
|
|
prerequisiteId: prereq.prerequisiteId
|
|
});
|
|
result.canStart = false;
|
|
break;
|
|
|
|
case 'not_started':
|
|
if (prereq.isBlocking || strictMode) {
|
|
result.errors.push({
|
|
code: 'PREREQUISITE_NOT_STARTED',
|
|
message: `Prérequis non démarré: ${prereq.prerequisiteName}`,
|
|
severity: 'error',
|
|
prerequisiteId: prereq.prerequisiteId
|
|
});
|
|
result.blockedBy.push(prereq.prerequisiteName);
|
|
result.canStart = false;
|
|
} else {
|
|
result.warnings.push({
|
|
code: 'PREREQUISITE_NOT_STARTED_WARNING',
|
|
message: `Prérequis non démarré: ${prereq.prerequisiteName}`,
|
|
recommendation: 'Considérez démarrer ce prérequis en parallèle'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'in_progress':
|
|
if (prereq.isBlocking || strictMode) {
|
|
result.warnings.push({
|
|
code: 'PREREQUISITE_IN_PROGRESS',
|
|
message: `Prérequis en cours: ${prereq.prerequisiteName}`,
|
|
recommendation: 'Attendez la fin de cette phase ou vérifiez si un démarrage parallèle est possible'
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'completed':
|
|
// Tout va bien
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Valide les prérequis métier spécifiques
|
|
*/
|
|
private validateBusinessPrerequisites(
|
|
phase: PhaseChantier,
|
|
allPhases: PhaseChantier[],
|
|
result: PhaseValidationResult
|
|
): void {
|
|
// Règles métier spécifiques au BTP
|
|
this.validateBTPBusinessRules(phase, allPhases, result);
|
|
|
|
// Vérifier les compétences requises
|
|
this.validateRequiredSkills(phase, result);
|
|
|
|
// Vérifier les ressources disponibles
|
|
this.validateResourceAvailability(phase, result);
|
|
}
|
|
|
|
/**
|
|
* Valide les règles métier spécifiques au BTP
|
|
*/
|
|
private validateBTPBusinessRules(
|
|
phase: PhaseChantier,
|
|
allPhases: PhaseChantier[],
|
|
result: PhaseValidationResult
|
|
): void {
|
|
const phaseName = phase.nom.toLowerCase();
|
|
|
|
// Règles pour la maçonnerie
|
|
if (phaseName.includes('maçonnerie') || phaseName.includes('béton')) {
|
|
const fondations = allPhases.find(p =>
|
|
p.nom.toLowerCase().includes('fondation') ||
|
|
p.nom.toLowerCase().includes('excavation')
|
|
);
|
|
|
|
if (fondations && fondations.statut !== 'TERMINEE') {
|
|
result.errors.push({
|
|
code: 'FOUNDATION_NOT_COMPLETED',
|
|
message: 'Les fondations doivent être terminées avant les travaux de maçonnerie',
|
|
severity: 'error',
|
|
phaseId: fondations.id
|
|
});
|
|
result.canStart = false;
|
|
result.blockedBy.push('Fondations');
|
|
}
|
|
}
|
|
|
|
// Règles pour l'électricité
|
|
if (phaseName.includes('électric') || phaseName.includes('électro')) {
|
|
const grosOeuvre = allPhases.find(p =>
|
|
p.nom.toLowerCase().includes('gros œuvre') ||
|
|
p.nom.toLowerCase().includes('gros oeuvre') ||
|
|
p.nom.toLowerCase().includes('structure')
|
|
);
|
|
|
|
if (grosOeuvre && grosOeuvre.statut !== 'TERMINEE') {
|
|
result.warnings.push({
|
|
code: 'STRUCTURE_NOT_COMPLETED',
|
|
message: 'Il est recommandé d\'attendre la fin du gros œuvre',
|
|
recommendation: 'Certains travaux électriques peuvent commencer en parallèle selon les zones'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Règles pour la plomberie
|
|
if (phaseName.includes('plomberie') || phaseName.includes('sanitaire')) {
|
|
const cloisons = allPhases.find(p =>
|
|
p.nom.toLowerCase().includes('cloison') ||
|
|
p.nom.toLowerCase().includes('doublage')
|
|
);
|
|
|
|
if (cloisons && cloisons.statut === 'TERMINEE') {
|
|
result.warnings.push({
|
|
code: 'PARTITIONS_ALREADY_DONE',
|
|
message: 'Les cloisons sont déjà terminées - travaux de plomberie plus complexes',
|
|
recommendation: 'Prévoir des saignées ou passages techniques'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Règles pour les finitions
|
|
if (phaseName.includes('peinture') || phaseName.includes('revêtement') || phaseName.includes('carrelage')) {
|
|
const secondOeuvre = allPhases.filter(p => {
|
|
const nom = p.nom.toLowerCase();
|
|
return nom.includes('électric') || nom.includes('plomberie') || nom.includes('chauffage');
|
|
});
|
|
|
|
const unfinishedSecondOeuvre = secondOeuvre.filter(p => p.statut !== 'TERMINEE');
|
|
|
|
if (unfinishedSecondOeuvre.length > 0) {
|
|
result.warnings.push({
|
|
code: 'SECOND_WORK_NOT_COMPLETED',
|
|
message: 'Certains travaux de second œuvre ne sont pas terminés',
|
|
recommendation: 'Terminer électricité, plomberie et chauffage avant les finitions'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valide les compétences requises
|
|
*/
|
|
private validateRequiredSkills(phase: PhaseChantier, result: PhaseValidationResult): void {
|
|
// Cette validation serait étoffée avec une vraie base de données des compétences
|
|
const requiredSkills = this.getRequiredSkillsForPhase(phase);
|
|
|
|
if (requiredSkills.length > 0) {
|
|
result.warnings.push({
|
|
code: 'SKILLS_REQUIRED',
|
|
message: `Compétences requises: ${requiredSkills.join(', ')}`,
|
|
recommendation: 'Vérifiez la disponibilité des artisans qualifiés'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valide la disponibilité des ressources
|
|
*/
|
|
private validateResourceAvailability(phase: PhaseChantier, result: PhaseValidationResult): void {
|
|
// Vérifications météorologiques pour certaines phases
|
|
if (this.isWeatherSensitivePhase(phase)) {
|
|
const season = this.getCurrentSeason();
|
|
if (season === 'winter') {
|
|
result.warnings.push({
|
|
code: 'WEATHER_SENSITIVE_WINTER',
|
|
message: 'Phase sensible aux conditions météorologiques - période hivernale',
|
|
recommendation: 'Prévoir des protections contre le gel et les intempéries'
|
|
});
|
|
}
|
|
}
|
|
|
|
// Vérification des matériaux
|
|
result.warnings.push({
|
|
code: 'MATERIALS_CHECK',
|
|
message: 'Vérifiez la disponibilité des matériaux',
|
|
recommendation: 'Confirmez les livraisons avant le démarrage'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Valide les contraintes temporelles
|
|
*/
|
|
private validateTemporalConstraints(
|
|
phase: PhaseChantier,
|
|
allPhases: PhaseChantier[],
|
|
result: PhaseValidationResult
|
|
): void {
|
|
const today = new Date();
|
|
|
|
// Vérifier les chevauchements problématiques
|
|
const overlappingPhases = this.findOverlappingPhases(phase, allPhases);
|
|
|
|
overlappingPhases.forEach(overlapping => {
|
|
if (this.arePhasesMutuallyExclusive(phase, overlapping)) {
|
|
result.errors.push({
|
|
code: 'PHASE_CONFLICT',
|
|
message: `Conflit avec la phase: ${overlapping.nom}`,
|
|
severity: 'error',
|
|
phaseId: overlapping.id
|
|
});
|
|
result.canStart = false;
|
|
}
|
|
});
|
|
|
|
// Vérifier les délais critiques
|
|
if (phase.critique && phase.dateFinPrevue) {
|
|
const dateFin = new Date(phase.dateFinPrevue);
|
|
const diffDays = Math.ceil((dateFin.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 7) {
|
|
result.warnings.push({
|
|
code: 'CRITICAL_PHASE_URGENT',
|
|
message: 'Phase critique avec délai serré',
|
|
recommendation: 'Mobiliser des ressources supplémentaires'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Obtient le statut des prérequis d'une phase
|
|
*/
|
|
private getPrerequisiteStatuses(phase: PhaseChantier, allPhases: PhaseChantier[]): PrerequisiteStatus[] {
|
|
if (!phase.prerequis) return [];
|
|
|
|
return phase.prerequis.map(prerequisiteId => {
|
|
const prerequisitePhase = allPhases.find(p => p.id === prerequisiteId);
|
|
|
|
return {
|
|
prerequisiteId,
|
|
prerequisiteName: prerequisitePhase?.nom || prerequisiteId,
|
|
status: prerequisitePhase ?
|
|
(prerequisitePhase.statut === 'TERMINEE' ? 'completed' :
|
|
prerequisitePhase.statut === 'EN_COURS' ? 'in_progress' : 'not_started') :
|
|
'not_found',
|
|
completionDate: prerequisitePhase?.dateFinReelle ? new Date(prerequisitePhase.dateFinReelle) : undefined,
|
|
isBlocking: this.isBlockingPrerequisite(prerequisiteId, phase)
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Détermine si un prérequis est bloquant
|
|
*/
|
|
private isBlockingPrerequisite(prerequisiteId: string, phase: PhaseChantier): boolean {
|
|
// Logique pour déterminer si un prérequis est critique/bloquant
|
|
// Basée sur le type de phase et les règles métier
|
|
const criticalPrerequisites = [
|
|
'fondations', 'excavation', 'gros-oeuvre', 'structure'
|
|
];
|
|
|
|
return criticalPrerequisites.some(critical =>
|
|
prerequisiteId.toLowerCase().includes(critical)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Obtient les compétences requises pour une phase
|
|
*/
|
|
private getRequiredSkillsForPhase(phase: PhaseChantier): string[] {
|
|
const phaseName = phase.nom.toLowerCase();
|
|
const skills: string[] = [];
|
|
|
|
if (phaseName.includes('maçonnerie')) skills.push('Maçon');
|
|
if (phaseName.includes('électric')) skills.push('Électricien');
|
|
if (phaseName.includes('plomberie')) skills.push('Plombier');
|
|
if (phaseName.includes('charpente')) skills.push('Charpentier');
|
|
if (phaseName.includes('couverture')) skills.push('Couvreur');
|
|
if (phaseName.includes('peinture')) skills.push('Peintre');
|
|
if (phaseName.includes('carrelage')) skills.push('Carreleur');
|
|
|
|
return skills;
|
|
}
|
|
|
|
/**
|
|
* Détermine si une phase est sensible aux conditions météorologiques
|
|
*/
|
|
private isWeatherSensitivePhase(phase: PhaseChantier): boolean {
|
|
const weatherSensitive = [
|
|
'couverture', 'étanchéité', 'façade', 'maçonnerie extérieure',
|
|
'terrassement', 'fondations', 'béton'
|
|
];
|
|
|
|
return weatherSensitive.some(sensitive =>
|
|
phase.nom.toLowerCase().includes(sensitive)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Obtient la saison actuelle
|
|
*/
|
|
private getCurrentSeason(): 'spring' | 'summer' | 'fall' | 'winter' {
|
|
const month = new Date().getMonth() + 1;
|
|
if (month >= 3 && month <= 5) return 'spring';
|
|
if (month >= 6 && month <= 8) return 'summer';
|
|
if (month >= 9 && month <= 11) return 'fall';
|
|
return 'winter';
|
|
}
|
|
|
|
/**
|
|
* Trouve les phases qui se chevauchent temporellement
|
|
*/
|
|
private findOverlappingPhases(phase: PhaseChantier, allPhases: PhaseChantier[]): PhaseChantier[] {
|
|
if (!phase.dateDebutPrevue || !phase.dateFinPrevue) return [];
|
|
|
|
const phaseStart = new Date(phase.dateDebutPrevue);
|
|
const phaseEnd = new Date(phase.dateFinPrevue);
|
|
|
|
return allPhases.filter(otherPhase => {
|
|
if (otherPhase.id === phase.id) return false;
|
|
if (!otherPhase.dateDebutPrevue || !otherPhase.dateFinPrevue) return false;
|
|
|
|
const otherStart = new Date(otherPhase.dateDebutPrevue);
|
|
const otherEnd = new Date(otherPhase.dateFinPrevue);
|
|
|
|
return (phaseStart <= otherEnd && phaseEnd >= otherStart);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Détermine si deux phases sont mutuellement exclusives
|
|
*/
|
|
private arePhasesMutuallyExclusive(phase1: PhaseChantier, phase2: PhaseChantier): boolean {
|
|
const exclusiveGroups = [
|
|
['maçonnerie', 'béton'],
|
|
['peinture', 'électricité'],
|
|
['carrelage', 'plomberie']
|
|
];
|
|
|
|
return exclusiveGroups.some(group => {
|
|
const phase1InGroup = group.some(term => phase1.nom.toLowerCase().includes(term));
|
|
const phase2InGroup = group.some(term => phase2.nom.toLowerCase().includes(term));
|
|
return phase1InGroup && phase2InGroup;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Valide l'ensemble d'un planning de phases
|
|
*/
|
|
validateProjectSchedule(phases: PhaseChantier[]): {
|
|
isValid: boolean;
|
|
globalErrors: ValidationError[];
|
|
phaseValidations: Map<string, PhaseValidationResult>;
|
|
} {
|
|
const phaseValidations = new Map<string, PhaseValidationResult>();
|
|
const globalErrors: ValidationError[] = [];
|
|
|
|
// Valider chaque phase individuellement
|
|
phases.forEach(phase => {
|
|
const validation = this.validatePhaseStart(phase, phases);
|
|
phaseValidations.set(phase.id!, validation);
|
|
});
|
|
|
|
// Validations globales
|
|
this.validateGlobalScheduleConstraints(phases, globalErrors);
|
|
|
|
const isValid = globalErrors.length === 0 &&
|
|
Array.from(phaseValidations.values()).every(v => v.readyToStart || v.errors.length === 0);
|
|
|
|
return {
|
|
isValid,
|
|
globalErrors,
|
|
phaseValidations
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Valide les contraintes globales du planning
|
|
*/
|
|
private validateGlobalScheduleConstraints(phases: PhaseChantier[], globalErrors: ValidationError[]): void {
|
|
// Vérifier l'ordre logique des phases
|
|
const orderedPhases = phases
|
|
.filter(p => p.ordreExecution !== undefined)
|
|
.sort((a, b) => (a.ordreExecution || 0) - (b.ordreExecution || 0));
|
|
|
|
for (let i = 1; i < orderedPhases.length; i++) {
|
|
const currentPhase = orderedPhases[i];
|
|
const previousPhase = orderedPhases[i - 1];
|
|
|
|
if (currentPhase.dateDebutPrevue && previousPhase.dateFinPrevue) {
|
|
const currentStart = new Date(currentPhase.dateDebutPrevue);
|
|
const previousEnd = new Date(previousPhase.dateFinPrevue);
|
|
|
|
if (currentStart < previousEnd) {
|
|
globalErrors.push({
|
|
code: 'SCHEDULE_ORDER_VIOLATION',
|
|
message: `La phase "${currentPhase.nom}" commence avant la fin de "${previousPhase.nom}"`,
|
|
severity: 'warning',
|
|
phaseId: currentPhase.id
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vérifier la durée totale du projet
|
|
if (phases.length > 0) {
|
|
const startDates = phases
|
|
.filter(p => p.dateDebutPrevue)
|
|
.map(p => new Date(p.dateDebutPrevue!));
|
|
const endDates = phases
|
|
.filter(p => p.dateFinPrevue)
|
|
.map(p => new Date(p.dateFinPrevue!));
|
|
|
|
if (startDates.length > 0 && endDates.length > 0) {
|
|
const projectStart = new Date(Math.min(...startDates.map(d => d.getTime())));
|
|
const projectEnd = new Date(Math.max(...endDates.map(d => d.getTime())));
|
|
const projectDuration = Math.ceil((projectEnd.getTime() - projectStart.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
if (projectDuration > 730) { // 2 ans
|
|
globalErrors.push({
|
|
code: 'PROJECT_TOO_LONG',
|
|
message: `Durée du projet très longue: ${projectDuration} jours`,
|
|
severity: 'warning'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export default new PhaseValidationService(); |