Files
btpxpress-frontend/services/budgetCoherenceService.ts
2025-10-01 01:39:07 +00:00

283 lines
11 KiB
TypeScript

/**
* 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();