283 lines
11 KiB
TypeScript
Executable File
283 lines
11 KiB
TypeScript
Executable File
/**
|
|
* Service pour gérer la cohérence des budgets entre chantiers et phases
|
|
* Assure que le montant_prevu du chantier correspond à la somme des budget_prevu des phases
|
|
*/
|
|
|
|
import { API_CONFIG } from '../config/api';
|
|
import axios from 'axios';
|
|
|
|
interface BudgetCoherence {
|
|
chantierId: string;
|
|
chantierNom: string;
|
|
budgetChantier: number;
|
|
budgetPhasesTotal: number;
|
|
ecartAbsolu: number;
|
|
ecartPourcentage: number;
|
|
coherent: boolean;
|
|
nombrePhases: number;
|
|
}
|
|
|
|
interface ValidationBudget {
|
|
valide: boolean;
|
|
message: string;
|
|
recommandation?: 'METTRE_A_JOUR_CHANTIER' | 'AJUSTER_PHASES' | 'AUCUNE_ACTION';
|
|
nouveauBudgetSuggere?: number;
|
|
}
|
|
|
|
class BudgetCoherenceService {
|
|
private api = axios.create({
|
|
baseURL: API_CONFIG.baseURL,
|
|
timeout: API_CONFIG.timeout,
|
|
headers: API_CONFIG.headers,
|
|
});
|
|
|
|
constructor() {
|
|
// Interceptor pour ajouter le token Keycloak
|
|
this.api.interceptors.request.use(
|
|
async (config) => {
|
|
// Vérifier si Keycloak est initialisé et l'utilisateur authentifié
|
|
if (typeof window !== 'undefined') {
|
|
const { keycloak, KEYCLOAK_TIMEOUTS } = await import('../config/keycloak');
|
|
|
|
if (keycloak.authenticated) {
|
|
try {
|
|
// Rafraîchir le token si nécessaire
|
|
await keycloak.updateToken(KEYCLOAK_TIMEOUTS.TOKEN_REFRESH_BEFORE_EXPIRY);
|
|
|
|
// Ajouter le token Bearer à l'en-tête Authorization
|
|
if (keycloak.token) {
|
|
config.headers['Authorization'] = `Bearer ${keycloak.token}`;
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur lors de la mise à jour du token Keycloak:', error);
|
|
keycloak.login();
|
|
throw error;
|
|
}
|
|
} else {
|
|
// Fallback vers l'ancien système pour la rétrocompatibilité
|
|
let token = null;
|
|
try {
|
|
const authTokenItem = sessionStorage.getItem('auth_token') || localStorage.getItem('auth_token');
|
|
if (authTokenItem) {
|
|
const parsed = JSON.parse(authTokenItem);
|
|
token = parsed.value;
|
|
}
|
|
} catch (e) {
|
|
token = localStorage.getItem('token');
|
|
}
|
|
|
|
if (token) {
|
|
config.headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
}
|
|
}
|
|
return config;
|
|
},
|
|
(error) => Promise.reject(error)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Vérifier la cohérence budgétaire pour un chantier donné
|
|
*/
|
|
async verifierCoherence(chantierId: string): Promise<BudgetCoherence> {
|
|
try {
|
|
// Pour l'instant, on fait toujours le calcul côté client
|
|
// L'endpoint backend sera implémenté plus tard
|
|
const [chantierResponse, phasesResponse] = await Promise.all([
|
|
this.api.get(`/api/v1/chantiers/${chantierId}`),
|
|
this.api.get(`/api/v1/phases-chantier/chantier/${chantierId}`)
|
|
]);
|
|
|
|
const chantier = chantierResponse.data;
|
|
const phases = phasesResponse.data || [];
|
|
|
|
const budgetChantier = parseFloat(chantier.montantPrevu || 0);
|
|
const budgetPhasesTotal = phases.reduce((sum: number, phase: any) =>
|
|
sum + parseFloat(phase.budgetPrevu || 0), 0);
|
|
|
|
const ecartAbsolu = Math.abs(budgetChantier - budgetPhasesTotal);
|
|
const ecartPourcentage = budgetChantier > 0 ? (ecartAbsolu / budgetChantier) * 100 : 0;
|
|
|
|
return {
|
|
chantierId,
|
|
chantierNom: chantier.nom,
|
|
budgetChantier,
|
|
budgetPhasesTotal,
|
|
ecartAbsolu,
|
|
ecartPourcentage,
|
|
coherent: ecartPourcentage <= 5, // Tolérance de 5%
|
|
nombrePhases: phases.length
|
|
};
|
|
} catch (error) {
|
|
console.warn('Erreur lors de la vérification de cohérence:', error);
|
|
// Retourner des valeurs par défaut en cas d'erreur
|
|
return {
|
|
chantierId,
|
|
chantierNom: 'Chantier inconnu',
|
|
budgetChantier: 0,
|
|
budgetPhasesTotal: 0,
|
|
ecartAbsolu: 0,
|
|
ecartPourcentage: 0,
|
|
coherent: true, // Considérer comme cohérent en cas d'erreur
|
|
nombrePhases: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Valider un budget de phases avant génération
|
|
*/
|
|
async validerBudgetPhases(chantierId: string, budgetPhasesPrevu: number): Promise<ValidationBudget> {
|
|
try {
|
|
const coherence = await this.verifierCoherence(chantierId);
|
|
const nouveauTotal = budgetPhasesPrevu;
|
|
const ecart = Math.abs(coherence.budgetChantier - nouveauTotal);
|
|
const ecartPourcentage = coherence.budgetChantier > 0 ? (ecart / coherence.budgetChantier) * 100 : 0;
|
|
|
|
if (ecartPourcentage <= 5) {
|
|
return {
|
|
valide: true,
|
|
message: 'Le budget des phases est cohérent avec le budget du chantier',
|
|
recommandation: 'AUCUNE_ACTION'
|
|
};
|
|
} else if (nouveauTotal > coherence.budgetChantier) {
|
|
return {
|
|
valide: false,
|
|
message: `Le budget des phases (${this.formatCurrency(nouveauTotal)}) dépasse le budget du chantier (${this.formatCurrency(coherence.budgetChantier)}) de ${ecartPourcentage.toFixed(1)}%`,
|
|
recommandation: 'METTRE_A_JOUR_CHANTIER',
|
|
nouveauBudgetSuggere: Math.ceil(nouveauTotal * 1.1) // +10% de marge
|
|
};
|
|
} else {
|
|
return {
|
|
valide: false,
|
|
message: `Le budget des phases (${this.formatCurrency(nouveauTotal)}) est inférieur au budget du chantier (${this.formatCurrency(coherence.budgetChantier)}) de ${ecartPourcentage.toFixed(1)}%`,
|
|
recommandation: 'AJUSTER_PHASES',
|
|
nouveauBudgetSuggere: coherence.budgetChantier
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Erreur lors de la validation budgétaire:', error);
|
|
return {
|
|
valide: true, // En cas d'erreur, on laisse passer
|
|
message: 'Impossible de valider la cohérence budgétaire'
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour le budget du chantier pour le faire correspondre aux phases
|
|
*/
|
|
async synchroniserBudgetChantier(chantierId: string, nouveauBudget: number): Promise<boolean> {
|
|
try {
|
|
await this.api.put(`/api/v1/chantiers/${chantierId}`, {
|
|
montantPrevu: nouveauBudget
|
|
});
|
|
console.log(`Budget du chantier mis à jour: ${this.formatCurrency(nouveauBudget)}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Erreur lors de la mise à jour du budget:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Suggérer une répartition équilibrée du budget sur les phases
|
|
*/
|
|
suggererRepartitionBudget(budgetTotal: number, phases: any[]): any[] {
|
|
if (!phases.length) return [];
|
|
|
|
// Répartition basée sur la complexité/durée des phases
|
|
const phasesAvecPoids = phases.map(phase => ({
|
|
...phase,
|
|
poids: this.calculerPoidsPhase(phase)
|
|
}));
|
|
|
|
const poidsTotal = phasesAvecPoids.reduce((sum, p) => sum + p.poids, 0);
|
|
|
|
return phasesAvecPoids.map(phase => ({
|
|
...phase,
|
|
budgetSuggere: Math.round((phase.poids / poidsTotal) * budgetTotal)
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Calculer le poids d'une phase pour la répartition budgétaire
|
|
*/
|
|
private calculerPoidsPhase(phase: any): number {
|
|
let poids = 1;
|
|
|
|
// Basé sur la durée
|
|
if (phase.dureeEstimee) {
|
|
poids *= Math.max(1, phase.dureeEstimee / 5); // Phases plus longues = plus de poids
|
|
}
|
|
|
|
// Basé sur le type de phase
|
|
const typesPoids: Record<string, number> = {
|
|
'GROS_OEUVRE': 2.0,
|
|
'FONDATIONS': 1.8,
|
|
'CHARPENTE': 1.5,
|
|
'SECOND_OEUVRE': 1.2,
|
|
'FINITIONS': 1.0,
|
|
'EQUIPEMENTS': 1.3
|
|
};
|
|
|
|
if (phase.categorieMetier && typesPoids[phase.categorieMetier]) {
|
|
poids *= typesPoids[phase.categorieMetier];
|
|
}
|
|
|
|
// Phase critique = plus de poids
|
|
if (phase.obligatoire || phase.critique) {
|
|
poids *= 1.2;
|
|
}
|
|
|
|
return poids;
|
|
}
|
|
|
|
/**
|
|
* Formater un montant en devise
|
|
*/
|
|
private formatCurrency(amount: number): string {
|
|
return new Intl.NumberFormat('fr-FR', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 0
|
|
}).format(amount);
|
|
}
|
|
|
|
/**
|
|
* Obtenir des recommandations budgétaires pour un chantier
|
|
*/
|
|
async obtenirRecommandations(chantierId: string): Promise<string[]> {
|
|
const coherence = await this.verifierCoherence(chantierId);
|
|
const recommandations: string[] = [];
|
|
|
|
if (!coherence.coherent) {
|
|
if (coherence.budgetPhasesTotal > coherence.budgetChantier) {
|
|
recommandations.push(
|
|
`💰 Le budget des phases dépasse celui du chantier de ${this.formatCurrency(coherence.ecartAbsolu)}`
|
|
);
|
|
recommandations.push(
|
|
`🔧 Recommandation : Augmenter le budget du chantier à ${this.formatCurrency(coherence.budgetPhasesTotal * 1.1)}`
|
|
);
|
|
} else {
|
|
recommandations.push(
|
|
`📉 Le budget des phases est inférieur à celui du chantier de ${this.formatCurrency(coherence.ecartAbsolu)}`
|
|
);
|
|
recommandations.push(
|
|
`🔧 Recommandation : Réajuster les budgets des phases ou prévoir des phases supplémentaires`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (coherence.nombrePhases === 0) {
|
|
recommandations.push(
|
|
`⚠️ Aucune phase définie pour ce chantier. Utilisez l'assistant de génération de phases.`
|
|
);
|
|
}
|
|
|
|
return recommandations;
|
|
}
|
|
}
|
|
|
|
export default new BudgetCoherenceService(); |