@wasto/sdk 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +7 -0
- package/dist/index.mjs +7 -0
- package/package.json +1 -1
- package/src/client.ts +701 -692
- package/src/dashboard-apps.ts +350 -350
- package/src/dto/cms-app-settings.ts +91 -91
- package/src/enums.ts +298 -298
- package/src/free-tier.ts +56 -56
- package/src/index.ts +35 -35
- package/src/opening-hours/constants.ts +48 -48
- package/src/opening-hours/convert.ts +276 -276
- package/src/opening-hours/format.ts +99 -99
- package/src/opening-hours/index.ts +9 -9
- package/src/opening-hours/is-open-now.ts +38 -38
- package/src/opening-hours/resolve.ts +71 -71
- package/src/opening-hours/time.ts +74 -74
- package/src/opening-hours/timezone.ts +34 -34
- package/src/opening-hours/types.ts +53 -53
- package/src/opening-hours/validate.ts +182 -182
- package/src/roi/calculations.ts +732 -732
- package/src/roi/index.ts +1 -1
- package/src/types/app-preload.types.ts +10 -0
- package/src/types/asset-bookings.types.ts +19 -19
- package/src/types/asset-interventions.types.ts +24 -24
- package/src/types/assets.types.ts +68 -68
- package/src/types/auth.types.ts +37 -37
- package/src/types/blog-posts.types.ts +19 -19
- package/src/types/calendar-pdf.types.ts +24 -24
- package/src/types/calendo-events.types.ts +64 -64
- package/src/types/challenges.types.ts +30 -30
- package/src/types/chatbot.types.ts +82 -82
- package/src/types/cities.types.ts +27 -27
- package/src/types/city-groups.types.ts +46 -46
- package/src/types/cms.types.ts +87 -87
- package/src/types/field-reports.types.ts +66 -66
- package/src/types/free-tier.types.ts +54 -54
- package/src/types/index.ts +33 -32
- package/src/types/iot.types.ts +31 -31
- package/src/types/maintenance.types.ts +44 -44
- package/src/types/mapso.types.ts +146 -146
- package/src/types/notifications.types.ts +68 -68
- package/src/types/onboarding.types.ts +117 -117
- package/src/types/operational-zones.types.ts +70 -70
- package/src/types/pagination.types.ts +33 -33
- package/src/types/providers.types.ts +29 -29
- package/src/types/quiz-questions.types.ts +59 -59
- package/src/types/recycling-points.types.ts +47 -47
- package/src/types/roi.types.ts +60 -60
- package/src/types/surveys.types.ts +103 -103
- package/src/types/tickets.types.ts +65 -65
- package/src/types/users.types.ts +30 -30
- package/src/types/waste-collection-places-query.types.ts +4 -4
- package/src/types/waste-collection-places.types.ts +33 -33
- package/src/types/waste-types.types.ts +21 -21
package/src/roi/calculations.ts
CHANGED
|
@@ -1,732 +1,732 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Moteur ROI déchets — partagé simulateur landing / API / console.
|
|
3
|
-
* Ordres de grandeur pédagogiques (non contractuels).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export const ROI_DEFAULTS = {
|
|
7
|
-
annualTonnage: 12_000,
|
|
8
|
-
errorRatePercent: 25,
|
|
9
|
-
costPerBadTonne: 300,
|
|
10
|
-
/** Habitants par foyer (proxy si householdCount absent). */
|
|
11
|
-
peoplePerHousehold: 2.2,
|
|
12
|
-
} as const
|
|
13
|
-
|
|
14
|
-
/** Coefficients Citeo outre-mer (barème pédagogique MVP). */
|
|
15
|
-
export const CITEO_DOM_MULTIPLIERS = {
|
|
16
|
-
lightPackaging: 2.3,
|
|
17
|
-
glass: 1.9,
|
|
18
|
-
paper: 4.3,
|
|
19
|
-
communication: 1.25,
|
|
20
|
-
ambassadors: 1.25,
|
|
21
|
-
/** Facteur composite simplifié pour majorer un soutien Citeo agrégé saisi. */
|
|
22
|
-
compositeSupport: 2.3,
|
|
23
|
-
} as const
|
|
24
|
-
|
|
25
|
-
/** Départements / préfixes INSEE DOM-COM (métropole exclue). */
|
|
26
|
-
export const DOM_INSEE_PREFIXES = [
|
|
27
|
-
'971', // Guadeloupe
|
|
28
|
-
'972', // Martinique
|
|
29
|
-
'973', // Guyane
|
|
30
|
-
'974', // La Réunion
|
|
31
|
-
'976', // Mayotte
|
|
32
|
-
] as const
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Trajectoire TGAP enfouissement (€/t) — barème pédagogique MVP.
|
|
36
|
-
* 2025: 65, 2026: 69, puis +4 €/an jusqu'à 85 €/t en 2030.
|
|
37
|
-
*/
|
|
38
|
-
export const TGAP_LANDFILL_EUR_PER_TONNE: Readonly<Record<number, number>> = {
|
|
39
|
-
2024: 61,
|
|
40
|
-
2025: 65,
|
|
41
|
-
2026: 69,
|
|
42
|
-
2027: 73,
|
|
43
|
-
2028: 77,
|
|
44
|
-
2029: 81,
|
|
45
|
-
2030: 85,
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function currentFiscalYear(now = new Date()): number {
|
|
49
|
-
return now.getFullYear()
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function getTgapEurPerTonne(fiscalYear: number): number {
|
|
53
|
-
if (TGAP_LANDFILL_EUR_PER_TONNE[fiscalYear] != null) {
|
|
54
|
-
return TGAP_LANDFILL_EUR_PER_TONNE[fiscalYear]
|
|
55
|
-
}
|
|
56
|
-
if (fiscalYear < 2024) return 61
|
|
57
|
-
if (fiscalYear > 2030) return 85
|
|
58
|
-
// Interpolation linéaire hors table
|
|
59
|
-
const base = 65
|
|
60
|
-
return base + (fiscalYear - 2025) * 4
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Ajuste le coût / t mal orientée quand la TGAP change d'année.
|
|
65
|
-
* On conserve le « reste » (traitement hors TGAP) et on y ajoute la nouvelle TGAP.
|
|
66
|
-
*/
|
|
67
|
-
export function adjustCostPerBadTonneForTgap(opts: {
|
|
68
|
-
previousCostPerBadTonne: number
|
|
69
|
-
previousTgapEurPerTonne: number
|
|
70
|
-
nextTgapEurPerTonne: number
|
|
71
|
-
}): number {
|
|
72
|
-
const nonTgap = Math.max(
|
|
73
|
-
0,
|
|
74
|
-
opts.previousCostPerBadTonne - opts.previousTgapEurPerTonne,
|
|
75
|
-
)
|
|
76
|
-
return roundEuro(nonTgap + opts.nextTgapEurPerTonne)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export type AnnualRoiPoint = {
|
|
80
|
-
fiscalYear: number
|
|
81
|
-
tonnageOmr: number
|
|
82
|
-
errorRatePercent: number
|
|
83
|
-
costPerBadTonne: number
|
|
84
|
-
tgapEurPerTonne: number
|
|
85
|
-
/** GEC réel (snapshot / actions de l'année). */
|
|
86
|
-
realGecEur: number
|
|
87
|
-
/** GEC BAU : même tonnage/erreur de référence, TGAP/coût de l'année, sans réduction d'erreur. */
|
|
88
|
-
bauGecEur: number
|
|
89
|
-
cenEur: number | null
|
|
90
|
-
hasSnapshot: boolean
|
|
91
|
-
annualBilanConfirmed: boolean
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Série BAU vs réel pour le graphique pluriannuel.
|
|
96
|
-
* `reference` = première année connue (baseline historique).
|
|
97
|
-
*/
|
|
98
|
-
export function buildBauVsRealSeries(opts: {
|
|
99
|
-
fromYear: number
|
|
100
|
-
toYear: number
|
|
101
|
-
reference: {
|
|
102
|
-
tonnageOmr: number
|
|
103
|
-
errorRatePercent: number
|
|
104
|
-
costPerBadTonne: number
|
|
105
|
-
tgapEurPerTonne: number
|
|
106
|
-
}
|
|
107
|
-
years: Array<{
|
|
108
|
-
fiscalYear: number
|
|
109
|
-
tonnageOmr: number
|
|
110
|
-
errorRatePercent: number
|
|
111
|
-
costPerBadTonne: number
|
|
112
|
-
tgapEurPerTonne: number
|
|
113
|
-
gecEur: number
|
|
114
|
-
cenEur: number | null
|
|
115
|
-
annualBilanConfirmed?: boolean
|
|
116
|
-
}>
|
|
117
|
-
}): AnnualRoiPoint[] {
|
|
118
|
-
const byYear = new Map(opts.years.map((y) => [y.fiscalYear, y]))
|
|
119
|
-
const points: AnnualRoiPoint[] = []
|
|
120
|
-
for (let y = opts.fromYear; y <= opts.toYear; y++) {
|
|
121
|
-
const tgap = getTgapEurPerTonne(y)
|
|
122
|
-
const bauCost = adjustCostPerBadTonneForTgap({
|
|
123
|
-
previousCostPerBadTonne: opts.reference.costPerBadTonne,
|
|
124
|
-
previousTgapEurPerTonne: opts.reference.tgapEurPerTonne,
|
|
125
|
-
nextTgapEurPerTonne: tgap,
|
|
126
|
-
})
|
|
127
|
-
const bau = computeGec(
|
|
128
|
-
opts.reference.tonnageOmr,
|
|
129
|
-
opts.reference.errorRatePercent,
|
|
130
|
-
bauCost,
|
|
131
|
-
)
|
|
132
|
-
const snap = byYear.get(y)
|
|
133
|
-
if (snap) {
|
|
134
|
-
points.push({
|
|
135
|
-
fiscalYear: y,
|
|
136
|
-
tonnageOmr: snap.tonnageOmr,
|
|
137
|
-
errorRatePercent: snap.errorRatePercent,
|
|
138
|
-
costPerBadTonne: snap.costPerBadTonne,
|
|
139
|
-
tgapEurPerTonne: snap.tgapEurPerTonne,
|
|
140
|
-
realGecEur: snap.gecEur,
|
|
141
|
-
bauGecEur: bau.gecEur,
|
|
142
|
-
cenEur: snap.cenEur,
|
|
143
|
-
hasSnapshot: true,
|
|
144
|
-
annualBilanConfirmed: snap.annualBilanConfirmed === true,
|
|
145
|
-
})
|
|
146
|
-
} else {
|
|
147
|
-
points.push({
|
|
148
|
-
fiscalYear: y,
|
|
149
|
-
tonnageOmr: opts.reference.tonnageOmr,
|
|
150
|
-
errorRatePercent: opts.reference.errorRatePercent,
|
|
151
|
-
costPerBadTonne: bauCost,
|
|
152
|
-
tgapEurPerTonne: tgap,
|
|
153
|
-
realGecEur: bau.gecEur,
|
|
154
|
-
bauGecEur: bau.gecEur,
|
|
155
|
-
cenEur: null,
|
|
156
|
-
hasSnapshot: false,
|
|
157
|
-
annualBilanConfirmed: false,
|
|
158
|
-
})
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return points
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Scénarios pédagogiques alignés lead magnet landing. */
|
|
165
|
-
export const ROI_SCENARIO_DEFS = [
|
|
166
|
-
{
|
|
167
|
-
id: 'awareness' as const,
|
|
168
|
-
label: 'Sensibilisation seule',
|
|
169
|
-
errorReductionFactor: 0.12,
|
|
170
|
-
costBase: 8_000,
|
|
171
|
-
costPerTonne: 0.35,
|
|
172
|
-
},
|
|
173
|
-
{
|
|
174
|
-
id: 'controls' as const,
|
|
175
|
-
label: 'Formation + contrôles',
|
|
176
|
-
errorReductionFactor: 0.28,
|
|
177
|
-
costBase: 45_000,
|
|
178
|
-
costPerTonne: 1.8,
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
id: 'wasto' as const,
|
|
182
|
-
label: 'Wast-O (app + IA + pilotage)',
|
|
183
|
-
errorReductionFactor: 0.45,
|
|
184
|
-
costBase: 18_000,
|
|
185
|
-
costPerTonne: 0.9,
|
|
186
|
-
},
|
|
187
|
-
]
|
|
188
|
-
|
|
189
|
-
export type ImpactConfidenceLevel = 1 | 2 | 3
|
|
190
|
-
|
|
191
|
-
export type ImpactConfidence = {
|
|
192
|
-
level: ImpactConfidenceLevel
|
|
193
|
-
label: string
|
|
194
|
-
tone: 'low' | 'medium' | 'high'
|
|
195
|
-
message: string
|
|
196
|
-
/** Facteur 0–1 appliqué à la réduction d'erreur pour le calcul. */
|
|
197
|
-
impactFactor: number
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export type RoiActionInput = {
|
|
201
|
-
budgetCapexEur: number
|
|
202
|
-
budgetOpexEur: number
|
|
203
|
-
/** Réduction relative 0–100 (ex. 12 = −12 %). */
|
|
204
|
-
errorReductionPercent: number
|
|
205
|
-
status?: string
|
|
206
|
-
name?: string
|
|
207
|
-
id?: string
|
|
208
|
-
type?: string
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export type RoiEconomicsInput = {
|
|
212
|
-
annualTonnageOmr: number
|
|
213
|
-
errorRatePercent: number
|
|
214
|
-
costPerBadTonne: number
|
|
215
|
-
citeoSupportGainEur?: number
|
|
216
|
-
wastoAnnualCostEur?: number
|
|
217
|
-
/** Taux d'erreur baseline figé (sinon = errorRatePercent courant). */
|
|
218
|
-
baselineErrorRatePercent?: number | null
|
|
219
|
-
baselineAnnualTonnageOmr?: number | null
|
|
220
|
-
/** Si true, majore les soutiens Citeo (DOM). */
|
|
221
|
-
isDomTerritory?: boolean
|
|
222
|
-
fiscalYear?: number
|
|
223
|
-
tgapEurPerTonne?: number
|
|
224
|
-
/** Bilan annuel consolidé → peut monter l'ICI global à N3. */
|
|
225
|
-
annualBilanConfirmed?: boolean
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export type RoiLiveSignals = {
|
|
229
|
-
penetrationRatePercent: number
|
|
230
|
-
activeDevices30d: number
|
|
231
|
-
validatedVisionScans: number
|
|
232
|
-
challengeCompletions: number
|
|
233
|
-
terrainResponses: number
|
|
234
|
-
annualBilanConfirmed?: boolean
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export type RoiIndicators = {
|
|
238
|
-
/** Gisement Économique Capturable (pertes avant). */
|
|
239
|
-
gecEur: number
|
|
240
|
-
baselineBadTonnes: number
|
|
241
|
-
baselineErrorRatePercent: number
|
|
242
|
-
afterErrorRatePercent: number
|
|
243
|
-
/** Réduction combinée effective en points de % d'erreur (ex. 5.6). */
|
|
244
|
-
combinedErrorReductionPoints: number
|
|
245
|
-
/** Réduction relative combinée (ex. 22.4 pour −22,4 %). */
|
|
246
|
-
combinedErrorReductionPercent: number
|
|
247
|
-
stackingFormula: string
|
|
248
|
-
afterAnnualLossEur: number
|
|
249
|
-
afterBadTonnes: number
|
|
250
|
-
/** Économies brutes = pertes avant − pertes après. */
|
|
251
|
-
grossSavingsEur: number
|
|
252
|
-
/** Budgets des seules actions (hors abonnement). */
|
|
253
|
-
actionsBudgetEur: number
|
|
254
|
-
/** Ligne abonnement / contrat Wast-O. */
|
|
255
|
-
wastoAnnualCostEur: number
|
|
256
|
-
totalActionBudgetsEur: number
|
|
257
|
-
citeoSupportGainEur: number
|
|
258
|
-
citeoSupportBaseEur: number
|
|
259
|
-
citeoDomMultiplier: number
|
|
260
|
-
isDomTerritory: boolean
|
|
261
|
-
/**
|
|
262
|
-
* CEN = économies brutes + Citeo − budgets cumulés
|
|
263
|
-
* (aligné avec le numérateur du ROI).
|
|
264
|
-
*/
|
|
265
|
-
cenEur: number
|
|
266
|
-
/** ROI = (économies + Citeo) / budgets ; null si budget 0. */
|
|
267
|
-
roiMultiple: number | null
|
|
268
|
-
overallConfidence: ImpactConfidence
|
|
269
|
-
disclaimer: string
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export const ROI_DISCLAIMER =
|
|
273
|
-
'Ordres de grandeur pédagogiques fondés sur des hypothèses déclaratives (tonnages, taux d’erreur, budgets). Non contractuels — à affiner avec les données exploitant / SITOM / TGAP.'
|
|
274
|
-
|
|
275
|
-
function clamp(n: number, min: number, max: number): number {
|
|
276
|
-
return Math.min(max, Math.max(min, n))
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
export function roundEuro(n: number): number {
|
|
280
|
-
return Math.round(n)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
export function isDomInseeCode(codeInsee: string | null | undefined): boolean {
|
|
284
|
-
if (!codeInsee) return false
|
|
285
|
-
const normalized = codeInsee.trim()
|
|
286
|
-
return DOM_INSEE_PREFIXES.some((p) => normalized.startsWith(p))
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export function detectDomTerritory(opts: {
|
|
290
|
-
cityInseeCodes?: Array<string | null | undefined>
|
|
291
|
-
cityGroupCode?: string | null
|
|
292
|
-
cityGroupName?: string | null
|
|
293
|
-
}): boolean {
|
|
294
|
-
if (opts.cityInseeCodes?.some((c) => isDomInseeCode(c))) return true
|
|
295
|
-
const code = (opts.cityGroupCode ?? '').toUpperCase()
|
|
296
|
-
const name = (opts.cityGroupName ?? '').toUpperCase()
|
|
297
|
-
if (name.includes('CINOR') || name.includes('REUNION') || name.includes('RÉUNION')) {
|
|
298
|
-
return true
|
|
299
|
-
}
|
|
300
|
-
if (code.includes('974') || code.includes('CINOR')) return true
|
|
301
|
-
return false
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
export function applyCiteoDomMultiplier(
|
|
305
|
-
baseCiteoEur: number,
|
|
306
|
-
isDom: boolean,
|
|
307
|
-
): { baseEur: number; multiplier: number; effectiveEur: number } {
|
|
308
|
-
const baseEur = roundEuro(baseCiteoEur)
|
|
309
|
-
const multiplier = isDom ? CITEO_DOM_MULTIPLIERS.compositeSupport : 1
|
|
310
|
-
return {
|
|
311
|
-
baseEur,
|
|
312
|
-
multiplier,
|
|
313
|
-
effectiveEur: roundEuro(baseEur * multiplier),
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** GEC = tonnage × (taux erreur / 100) × €/t */
|
|
318
|
-
export function computeGec(
|
|
319
|
-
annualTonnage: number,
|
|
320
|
-
errorRatePercent: number,
|
|
321
|
-
costPerBadTonne: number,
|
|
322
|
-
): { gecEur: number; badTonnes: number } {
|
|
323
|
-
const badTonnes = annualTonnage * (errorRatePercent / 100)
|
|
324
|
-
return {
|
|
325
|
-
badTonnes: Math.round(badTonnes * 10) / 10,
|
|
326
|
-
gecEur: roundEuro(badTonnes * costPerBadTonne),
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Empilement multiplicatif des réductions relatives d'actions actives.
|
|
332
|
-
* newRate = baseline × Π(1 − r_i/100)
|
|
333
|
-
*/
|
|
334
|
-
export function applyStackedErrorReductions(
|
|
335
|
-
baselineErrorRatePercent: number,
|
|
336
|
-
reductionsPercent: number[],
|
|
337
|
-
): number {
|
|
338
|
-
let rate = baselineErrorRatePercent
|
|
339
|
-
for (const r of reductionsPercent) {
|
|
340
|
-
const factor = clamp(r, 0, 95) / 100
|
|
341
|
-
rate = rate * (1 - factor)
|
|
342
|
-
}
|
|
343
|
-
return clamp(Math.round(rate * 10) / 10, 1, 99)
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
export function isActionCountedInRoi(status?: string): boolean {
|
|
347
|
-
if (!status) return true
|
|
348
|
-
return status !== 'CANCELLED' && status !== 'PLANNED'
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* ICI — Indice de Confiance de l'Impact.
|
|
353
|
-
* 1 = déclaratif, 2 = proxy live, 3 = mesuré / représentatif.
|
|
354
|
-
*/
|
|
355
|
-
export function computeActionImpactConfidence(
|
|
356
|
-
action: RoiActionInput,
|
|
357
|
-
live: RoiLiveSignals,
|
|
358
|
-
): ImpactConfidence {
|
|
359
|
-
const type = (action.type ?? 'OTHER').toUpperCase()
|
|
360
|
-
const isApp = type === 'WASTO_APP'
|
|
361
|
-
const hasLiveDigital =
|
|
362
|
-
live.penetrationRatePercent > 0 ||
|
|
363
|
-
live.activeDevices30d > 0 ||
|
|
364
|
-
live.validatedVisionScans > 0 ||
|
|
365
|
-
live.challengeCompletions > 0
|
|
366
|
-
|
|
367
|
-
if (isApp && !hasLiveDigital) {
|
|
368
|
-
return {
|
|
369
|
-
level: 1,
|
|
370
|
-
label: 'Confiance faible',
|
|
371
|
-
tone: 'low',
|
|
372
|
-
message: 'Impact théorique (0 utilisateur actif)',
|
|
373
|
-
impactFactor: 0.25,
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (isApp && live.penetrationRatePercent < 2) {
|
|
378
|
-
return {
|
|
379
|
-
level: 1,
|
|
380
|
-
label: 'Confiance faible',
|
|
381
|
-
tone: 'low',
|
|
382
|
-
message: `Pénétration faible (${live.penetrationRatePercent} %) — impact bridé`,
|
|
383
|
-
impactFactor: 0.5,
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (isApp && live.penetrationRatePercent >= 5) {
|
|
388
|
-
return {
|
|
389
|
-
level: 2,
|
|
390
|
-
label: 'Confiance moyenne',
|
|
391
|
-
tone: 'medium',
|
|
392
|
-
message: 'Proxy live app disponible',
|
|
393
|
-
impactFactor: 1,
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (type === 'AWARENESS' || type === 'TRAINING' || type === 'CONTROLS') {
|
|
398
|
-
if (live.terrainResponses > 0) {
|
|
399
|
-
return {
|
|
400
|
-
level: 2,
|
|
401
|
-
label: 'Confiance moyenne',
|
|
402
|
-
tone: 'medium',
|
|
403
|
-
message: 'Appuyé par des données terrain',
|
|
404
|
-
impactFactor: 1,
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
return {
|
|
408
|
-
level: 1,
|
|
409
|
-
label: 'Confiance faible',
|
|
410
|
-
tone: 'low',
|
|
411
|
-
message: 'Impact déclaratif (pas encore de preuve mesurée)',
|
|
412
|
-
impactFactor: 0.85,
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
level: 1,
|
|
418
|
-
label: 'Confiance faible',
|
|
419
|
-
tone: 'low',
|
|
420
|
-
message: 'Impact déclaratif',
|
|
421
|
-
impactFactor: 0.85,
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
export function computeOverallConfidence(
|
|
426
|
-
actionConfidences: ImpactConfidence[],
|
|
427
|
-
opts?: { annualBilanConfirmed?: boolean },
|
|
428
|
-
): ImpactConfidence {
|
|
429
|
-
if (opts?.annualBilanConfirmed) {
|
|
430
|
-
return {
|
|
431
|
-
level: 3,
|
|
432
|
-
label: 'Confiance élevée',
|
|
433
|
-
tone: 'high',
|
|
434
|
-
message:
|
|
435
|
-
'Bilan annuel consolidé (TGAP / exploitant / coût du service) — niveau décideurs',
|
|
436
|
-
impactFactor: 1,
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
if (actionConfidences.length === 0) {
|
|
440
|
-
return {
|
|
441
|
-
level: 1,
|
|
442
|
-
label: 'Confiance faible',
|
|
443
|
-
tone: 'low',
|
|
444
|
-
message: 'Aucune action active',
|
|
445
|
-
impactFactor: 1,
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
const minLevel = Math.min(
|
|
449
|
-
...actionConfidences.map((c) => c.level),
|
|
450
|
-
) as ImpactConfidenceLevel
|
|
451
|
-
if (minLevel === 1) {
|
|
452
|
-
return {
|
|
453
|
-
level: 1,
|
|
454
|
-
label: 'Confiance faible',
|
|
455
|
-
tone: 'low',
|
|
456
|
-
message:
|
|
457
|
-
'ROI en partie théorique — au moins une action sans preuve live',
|
|
458
|
-
impactFactor: 1,
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
if (minLevel === 2) {
|
|
462
|
-
return {
|
|
463
|
-
level: 2,
|
|
464
|
-
label: 'Confiance moyenne',
|
|
465
|
-
tone: 'medium',
|
|
466
|
-
message: 'ROI appuyé par des proxies live',
|
|
467
|
-
impactFactor: 1,
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
return {
|
|
471
|
-
level: 3,
|
|
472
|
-
label: 'Confiance élevée',
|
|
473
|
-
tone: 'high',
|
|
474
|
-
message: 'ROI adossé à des preuves représentatives',
|
|
475
|
-
impactFactor: 1,
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
export function computeRoiIndicators(
|
|
480
|
-
economics: RoiEconomicsInput,
|
|
481
|
-
actions: RoiActionInput[],
|
|
482
|
-
live?: RoiLiveSignals,
|
|
483
|
-
): RoiIndicators {
|
|
484
|
-
const baselineRate =
|
|
485
|
-
economics.baselineErrorRatePercent ?? economics.errorRatePercent
|
|
486
|
-
const baselineTonnage =
|
|
487
|
-
economics.baselineAnnualTonnageOmr ?? economics.annualTonnageOmr
|
|
488
|
-
const cost = economics.costPerBadTonne
|
|
489
|
-
const liveSignals: RoiLiveSignals = live ?? {
|
|
490
|
-
penetrationRatePercent: 0,
|
|
491
|
-
activeDevices30d: 0,
|
|
492
|
-
validatedVisionScans: 0,
|
|
493
|
-
challengeCompletions: 0,
|
|
494
|
-
terrainResponses: 0,
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const before = computeGec(baselineTonnage, baselineRate, cost)
|
|
498
|
-
|
|
499
|
-
const counted = actions.filter((a) => isActionCountedInRoi(a.status))
|
|
500
|
-
const confidences = counted.map((a) =>
|
|
501
|
-
computeActionImpactConfidence(a, liveSignals),
|
|
502
|
-
)
|
|
503
|
-
const reductions = counted.map((a, i) => {
|
|
504
|
-
const factor = confidences[i]?.impactFactor ?? 1
|
|
505
|
-
return Math.max(0, a.errorReductionPercent) * factor
|
|
506
|
-
})
|
|
507
|
-
const afterRate =
|
|
508
|
-
reductions.length > 0
|
|
509
|
-
? applyStackedErrorReductions(baselineRate, reductions)
|
|
510
|
-
: economics.errorRatePercent
|
|
511
|
-
|
|
512
|
-
const combinedErrorReductionPoints =
|
|
513
|
-
Math.round((baselineRate - afterRate) * 10) / 10
|
|
514
|
-
const combinedErrorReductionPercent =
|
|
515
|
-
baselineRate > 0
|
|
516
|
-
? Math.round((1 - afterRate / baselineRate) * 1000) / 10
|
|
517
|
-
: 0
|
|
518
|
-
|
|
519
|
-
const after = computeGec(economics.annualTonnageOmr, afterRate, cost)
|
|
520
|
-
const grossSavingsEur = roundEuro(before.gecEur - after.gecEur)
|
|
521
|
-
|
|
522
|
-
const actionsBudgetEur = roundEuro(
|
|
523
|
-
counted.reduce(
|
|
524
|
-
(sum, a) => sum + (a.budgetCapexEur || 0) + (a.budgetOpexEur || 0),
|
|
525
|
-
0,
|
|
526
|
-
),
|
|
527
|
-
)
|
|
528
|
-
const wastoAnnualCostEur = roundEuro(economics.wastoAnnualCostEur ?? 0)
|
|
529
|
-
const totalActionBudgetsEur = roundEuro(actionsBudgetEur + wastoAnnualCostEur)
|
|
530
|
-
|
|
531
|
-
const isDomTerritory = economics.isDomTerritory === true
|
|
532
|
-
const citeo = applyCiteoDomMultiplier(
|
|
533
|
-
economics.citeoSupportGainEur ?? 0,
|
|
534
|
-
isDomTerritory,
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
const cenEur = roundEuro(
|
|
538
|
-
grossSavingsEur + citeo.effectiveEur - totalActionBudgetsEur,
|
|
539
|
-
)
|
|
540
|
-
const roiMultiple =
|
|
541
|
-
totalActionBudgetsEur > 0
|
|
542
|
-
? Math.round(
|
|
543
|
-
((grossSavingsEur + citeo.effectiveEur) / totalActionBudgetsEur) *
|
|
544
|
-
10,
|
|
545
|
-
) / 10
|
|
546
|
-
: null
|
|
547
|
-
|
|
548
|
-
const overallConfidence = computeOverallConfidence(confidences, {
|
|
549
|
-
annualBilanConfirmed:
|
|
550
|
-
economics.annualBilanConfirmed === true ||
|
|
551
|
-
liveSignals.annualBilanConfirmed === true,
|
|
552
|
-
})
|
|
553
|
-
const stackingParts = reductions
|
|
554
|
-
.map((r) => `(1 − ${Math.round(r * 10) / 10}/100)`)
|
|
555
|
-
.join(' × ')
|
|
556
|
-
|
|
557
|
-
return {
|
|
558
|
-
gecEur: before.gecEur,
|
|
559
|
-
baselineBadTonnes: before.badTonnes,
|
|
560
|
-
baselineErrorRatePercent: baselineRate,
|
|
561
|
-
afterErrorRatePercent: afterRate,
|
|
562
|
-
combinedErrorReductionPoints,
|
|
563
|
-
combinedErrorReductionPercent,
|
|
564
|
-
stackingFormula:
|
|
565
|
-
reductions.length > 0
|
|
566
|
-
? `taux_après = ${baselineRate} % × ${stackingParts}`
|
|
567
|
-
: 'Aucune réduction active',
|
|
568
|
-
afterAnnualLossEur: after.gecEur,
|
|
569
|
-
afterBadTonnes: after.badTonnes,
|
|
570
|
-
grossSavingsEur,
|
|
571
|
-
actionsBudgetEur,
|
|
572
|
-
wastoAnnualCostEur,
|
|
573
|
-
totalActionBudgetsEur,
|
|
574
|
-
citeoSupportGainEur: citeo.effectiveEur,
|
|
575
|
-
citeoSupportBaseEur: citeo.baseEur,
|
|
576
|
-
citeoDomMultiplier: citeo.multiplier,
|
|
577
|
-
isDomTerritory,
|
|
578
|
-
cenEur,
|
|
579
|
-
roiMultiple,
|
|
580
|
-
overallConfidence,
|
|
581
|
-
disclaimer: ROI_DISCLAIMER,
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export type ActionContribution = {
|
|
586
|
-
id?: string
|
|
587
|
-
name?: string
|
|
588
|
-
type?: string
|
|
589
|
-
shareEur: number
|
|
590
|
-
budgetEur: number
|
|
591
|
-
confidence: ImpactConfidence
|
|
592
|
-
effectiveReductionPercent: number
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/** Contribution approximative au prorata de la réduction effective (après ICI). */
|
|
596
|
-
export function allocateSavingsByAction(
|
|
597
|
-
grossSavingsEur: number,
|
|
598
|
-
actions: RoiActionInput[],
|
|
599
|
-
live?: RoiLiveSignals,
|
|
600
|
-
): ActionContribution[] {
|
|
601
|
-
const liveSignals: RoiLiveSignals = live ?? {
|
|
602
|
-
penetrationRatePercent: 0,
|
|
603
|
-
activeDevices30d: 0,
|
|
604
|
-
validatedVisionScans: 0,
|
|
605
|
-
challengeCompletions: 0,
|
|
606
|
-
terrainResponses: 0,
|
|
607
|
-
}
|
|
608
|
-
const counted = actions.filter((a) => isActionCountedInRoi(a.status))
|
|
609
|
-
const enriched = counted.map((a) => {
|
|
610
|
-
const confidence = computeActionImpactConfidence(a, liveSignals)
|
|
611
|
-
return {
|
|
612
|
-
action: a,
|
|
613
|
-
confidence,
|
|
614
|
-
effectiveReductionPercent:
|
|
615
|
-
Math.max(0, a.errorReductionPercent) * confidence.impactFactor,
|
|
616
|
-
}
|
|
617
|
-
})
|
|
618
|
-
const totalReduction = enriched.reduce(
|
|
619
|
-
(s, e) => s + e.effectiveReductionPercent,
|
|
620
|
-
0,
|
|
621
|
-
)
|
|
622
|
-
if (totalReduction <= 0 || enriched.length === 0) {
|
|
623
|
-
return enriched.map((e) => ({
|
|
624
|
-
id: e.action.id,
|
|
625
|
-
name: e.action.name,
|
|
626
|
-
type: e.action.type,
|
|
627
|
-
shareEur: 0,
|
|
628
|
-
budgetEur: roundEuro(
|
|
629
|
-
(e.action.budgetCapexEur || 0) + (e.action.budgetOpexEur || 0),
|
|
630
|
-
),
|
|
631
|
-
confidence: e.confidence,
|
|
632
|
-
effectiveReductionPercent: e.effectiveReductionPercent,
|
|
633
|
-
}))
|
|
634
|
-
}
|
|
635
|
-
return enriched.map((e) => {
|
|
636
|
-
const weight = e.effectiveReductionPercent / totalReduction
|
|
637
|
-
return {
|
|
638
|
-
id: e.action.id,
|
|
639
|
-
name: e.action.name,
|
|
640
|
-
type: e.action.type,
|
|
641
|
-
shareEur: roundEuro(grossSavingsEur * weight),
|
|
642
|
-
budgetEur: roundEuro(
|
|
643
|
-
(e.action.budgetCapexEur || 0) + (e.action.budgetOpexEur || 0),
|
|
644
|
-
),
|
|
645
|
-
confidence: e.confidence,
|
|
646
|
-
effectiveReductionPercent:
|
|
647
|
-
Math.round(e.effectiveReductionPercent * 10) / 10,
|
|
648
|
-
}
|
|
649
|
-
})
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
export function estimateHouseholds(
|
|
653
|
-
householdCount: number | null | undefined,
|
|
654
|
-
population: number | null | undefined,
|
|
655
|
-
): number {
|
|
656
|
-
if (householdCount && householdCount > 0) return householdCount
|
|
657
|
-
if (population && population > 0) {
|
|
658
|
-
return Math.round(population / ROI_DEFAULTS.peoplePerHousehold)
|
|
659
|
-
}
|
|
660
|
-
return 0
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
export function computePenetrationRate(
|
|
664
|
-
activeHouseholdsProxy: number,
|
|
665
|
-
householdCount: number,
|
|
666
|
-
): number {
|
|
667
|
-
if (householdCount <= 0) return 0
|
|
668
|
-
return Math.round((activeHouseholdsProxy / householdCount) * 1000) / 10
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/** Simulation pédagogique territoire 100k hab. (validation formules). */
|
|
672
|
-
export function simulateTerritory100kExample() {
|
|
673
|
-
const economics: RoiEconomicsInput = {
|
|
674
|
-
annualTonnageOmr: 24_000,
|
|
675
|
-
errorRatePercent: 25,
|
|
676
|
-
costPerBadTonne: 300,
|
|
677
|
-
wastoAnnualCostEur: 18_000,
|
|
678
|
-
citeoSupportGainEur: 40_000,
|
|
679
|
-
baselineErrorRatePercent: 25,
|
|
680
|
-
baselineAnnualTonnageOmr: 24_000,
|
|
681
|
-
}
|
|
682
|
-
const actions: RoiActionInput[] = [
|
|
683
|
-
{
|
|
684
|
-
id: '1',
|
|
685
|
-
name: 'Campagne sensibilisation',
|
|
686
|
-
type: 'AWARENESS',
|
|
687
|
-
status: 'IN_PROGRESS',
|
|
688
|
-
budgetCapexEur: 0,
|
|
689
|
-
budgetOpexEur: 12_000,
|
|
690
|
-
errorReductionPercent: 12,
|
|
691
|
-
},
|
|
692
|
-
{
|
|
693
|
-
id: '2',
|
|
694
|
-
name: 'Contrôles de bac',
|
|
695
|
-
type: 'CONTROLS',
|
|
696
|
-
status: 'IN_PROGRESS',
|
|
697
|
-
budgetCapexEur: 5_000,
|
|
698
|
-
budgetOpexEur: 35_000,
|
|
699
|
-
errorReductionPercent: 18,
|
|
700
|
-
},
|
|
701
|
-
{
|
|
702
|
-
id: '3',
|
|
703
|
-
name: 'Wast-O app',
|
|
704
|
-
type: 'WASTO_APP',
|
|
705
|
-
status: 'IN_PROGRESS',
|
|
706
|
-
budgetCapexEur: 0,
|
|
707
|
-
budgetOpexEur: 0,
|
|
708
|
-
errorReductionPercent: 20,
|
|
709
|
-
},
|
|
710
|
-
]
|
|
711
|
-
const live: RoiLiveSignals = {
|
|
712
|
-
penetrationRatePercent: 3,
|
|
713
|
-
activeDevices30d: 1200,
|
|
714
|
-
validatedVisionScans: 400,
|
|
715
|
-
challengeCompletions: 80,
|
|
716
|
-
terrainResponses: 50,
|
|
717
|
-
}
|
|
718
|
-
const indicators = computeRoiIndicators(economics, actions, live)
|
|
719
|
-
const households = estimateHouseholds(null, 100_000)
|
|
720
|
-
return {
|
|
721
|
-
population: 100_000,
|
|
722
|
-
households,
|
|
723
|
-
economics,
|
|
724
|
-
actions,
|
|
725
|
-
indicators,
|
|
726
|
-
contributions: allocateSavingsByAction(
|
|
727
|
-
indicators.grossSavingsEur,
|
|
728
|
-
actions,
|
|
729
|
-
live,
|
|
730
|
-
),
|
|
731
|
-
}
|
|
732
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Moteur ROI déchets — partagé simulateur landing / API / console.
|
|
3
|
+
* Ordres de grandeur pédagogiques (non contractuels).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ROI_DEFAULTS = {
|
|
7
|
+
annualTonnage: 12_000,
|
|
8
|
+
errorRatePercent: 25,
|
|
9
|
+
costPerBadTonne: 300,
|
|
10
|
+
/** Habitants par foyer (proxy si householdCount absent). */
|
|
11
|
+
peoplePerHousehold: 2.2,
|
|
12
|
+
} as const
|
|
13
|
+
|
|
14
|
+
/** Coefficients Citeo outre-mer (barème pédagogique MVP). */
|
|
15
|
+
export const CITEO_DOM_MULTIPLIERS = {
|
|
16
|
+
lightPackaging: 2.3,
|
|
17
|
+
glass: 1.9,
|
|
18
|
+
paper: 4.3,
|
|
19
|
+
communication: 1.25,
|
|
20
|
+
ambassadors: 1.25,
|
|
21
|
+
/** Facteur composite simplifié pour majorer un soutien Citeo agrégé saisi. */
|
|
22
|
+
compositeSupport: 2.3,
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
/** Départements / préfixes INSEE DOM-COM (métropole exclue). */
|
|
26
|
+
export const DOM_INSEE_PREFIXES = [
|
|
27
|
+
'971', // Guadeloupe
|
|
28
|
+
'972', // Martinique
|
|
29
|
+
'973', // Guyane
|
|
30
|
+
'974', // La Réunion
|
|
31
|
+
'976', // Mayotte
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Trajectoire TGAP enfouissement (€/t) — barème pédagogique MVP.
|
|
36
|
+
* 2025: 65, 2026: 69, puis +4 €/an jusqu'à 85 €/t en 2030.
|
|
37
|
+
*/
|
|
38
|
+
export const TGAP_LANDFILL_EUR_PER_TONNE: Readonly<Record<number, number>> = {
|
|
39
|
+
2024: 61,
|
|
40
|
+
2025: 65,
|
|
41
|
+
2026: 69,
|
|
42
|
+
2027: 73,
|
|
43
|
+
2028: 77,
|
|
44
|
+
2029: 81,
|
|
45
|
+
2030: 85,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function currentFiscalYear(now = new Date()): number {
|
|
49
|
+
return now.getFullYear()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getTgapEurPerTonne(fiscalYear: number): number {
|
|
53
|
+
if (TGAP_LANDFILL_EUR_PER_TONNE[fiscalYear] != null) {
|
|
54
|
+
return TGAP_LANDFILL_EUR_PER_TONNE[fiscalYear]
|
|
55
|
+
}
|
|
56
|
+
if (fiscalYear < 2024) return 61
|
|
57
|
+
if (fiscalYear > 2030) return 85
|
|
58
|
+
// Interpolation linéaire hors table
|
|
59
|
+
const base = 65
|
|
60
|
+
return base + (fiscalYear - 2025) * 4
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ajuste le coût / t mal orientée quand la TGAP change d'année.
|
|
65
|
+
* On conserve le « reste » (traitement hors TGAP) et on y ajoute la nouvelle TGAP.
|
|
66
|
+
*/
|
|
67
|
+
export function adjustCostPerBadTonneForTgap(opts: {
|
|
68
|
+
previousCostPerBadTonne: number
|
|
69
|
+
previousTgapEurPerTonne: number
|
|
70
|
+
nextTgapEurPerTonne: number
|
|
71
|
+
}): number {
|
|
72
|
+
const nonTgap = Math.max(
|
|
73
|
+
0,
|
|
74
|
+
opts.previousCostPerBadTonne - opts.previousTgapEurPerTonne,
|
|
75
|
+
)
|
|
76
|
+
return roundEuro(nonTgap + opts.nextTgapEurPerTonne)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type AnnualRoiPoint = {
|
|
80
|
+
fiscalYear: number
|
|
81
|
+
tonnageOmr: number
|
|
82
|
+
errorRatePercent: number
|
|
83
|
+
costPerBadTonne: number
|
|
84
|
+
tgapEurPerTonne: number
|
|
85
|
+
/** GEC réel (snapshot / actions de l'année). */
|
|
86
|
+
realGecEur: number
|
|
87
|
+
/** GEC BAU : même tonnage/erreur de référence, TGAP/coût de l'année, sans réduction d'erreur. */
|
|
88
|
+
bauGecEur: number
|
|
89
|
+
cenEur: number | null
|
|
90
|
+
hasSnapshot: boolean
|
|
91
|
+
annualBilanConfirmed: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Série BAU vs réel pour le graphique pluriannuel.
|
|
96
|
+
* `reference` = première année connue (baseline historique).
|
|
97
|
+
*/
|
|
98
|
+
export function buildBauVsRealSeries(opts: {
|
|
99
|
+
fromYear: number
|
|
100
|
+
toYear: number
|
|
101
|
+
reference: {
|
|
102
|
+
tonnageOmr: number
|
|
103
|
+
errorRatePercent: number
|
|
104
|
+
costPerBadTonne: number
|
|
105
|
+
tgapEurPerTonne: number
|
|
106
|
+
}
|
|
107
|
+
years: Array<{
|
|
108
|
+
fiscalYear: number
|
|
109
|
+
tonnageOmr: number
|
|
110
|
+
errorRatePercent: number
|
|
111
|
+
costPerBadTonne: number
|
|
112
|
+
tgapEurPerTonne: number
|
|
113
|
+
gecEur: number
|
|
114
|
+
cenEur: number | null
|
|
115
|
+
annualBilanConfirmed?: boolean
|
|
116
|
+
}>
|
|
117
|
+
}): AnnualRoiPoint[] {
|
|
118
|
+
const byYear = new Map(opts.years.map((y) => [y.fiscalYear, y]))
|
|
119
|
+
const points: AnnualRoiPoint[] = []
|
|
120
|
+
for (let y = opts.fromYear; y <= opts.toYear; y++) {
|
|
121
|
+
const tgap = getTgapEurPerTonne(y)
|
|
122
|
+
const bauCost = adjustCostPerBadTonneForTgap({
|
|
123
|
+
previousCostPerBadTonne: opts.reference.costPerBadTonne,
|
|
124
|
+
previousTgapEurPerTonne: opts.reference.tgapEurPerTonne,
|
|
125
|
+
nextTgapEurPerTonne: tgap,
|
|
126
|
+
})
|
|
127
|
+
const bau = computeGec(
|
|
128
|
+
opts.reference.tonnageOmr,
|
|
129
|
+
opts.reference.errorRatePercent,
|
|
130
|
+
bauCost,
|
|
131
|
+
)
|
|
132
|
+
const snap = byYear.get(y)
|
|
133
|
+
if (snap) {
|
|
134
|
+
points.push({
|
|
135
|
+
fiscalYear: y,
|
|
136
|
+
tonnageOmr: snap.tonnageOmr,
|
|
137
|
+
errorRatePercent: snap.errorRatePercent,
|
|
138
|
+
costPerBadTonne: snap.costPerBadTonne,
|
|
139
|
+
tgapEurPerTonne: snap.tgapEurPerTonne,
|
|
140
|
+
realGecEur: snap.gecEur,
|
|
141
|
+
bauGecEur: bau.gecEur,
|
|
142
|
+
cenEur: snap.cenEur,
|
|
143
|
+
hasSnapshot: true,
|
|
144
|
+
annualBilanConfirmed: snap.annualBilanConfirmed === true,
|
|
145
|
+
})
|
|
146
|
+
} else {
|
|
147
|
+
points.push({
|
|
148
|
+
fiscalYear: y,
|
|
149
|
+
tonnageOmr: opts.reference.tonnageOmr,
|
|
150
|
+
errorRatePercent: opts.reference.errorRatePercent,
|
|
151
|
+
costPerBadTonne: bauCost,
|
|
152
|
+
tgapEurPerTonne: tgap,
|
|
153
|
+
realGecEur: bau.gecEur,
|
|
154
|
+
bauGecEur: bau.gecEur,
|
|
155
|
+
cenEur: null,
|
|
156
|
+
hasSnapshot: false,
|
|
157
|
+
annualBilanConfirmed: false,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return points
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Scénarios pédagogiques alignés lead magnet landing. */
|
|
165
|
+
export const ROI_SCENARIO_DEFS = [
|
|
166
|
+
{
|
|
167
|
+
id: 'awareness' as const,
|
|
168
|
+
label: 'Sensibilisation seule',
|
|
169
|
+
errorReductionFactor: 0.12,
|
|
170
|
+
costBase: 8_000,
|
|
171
|
+
costPerTonne: 0.35,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'controls' as const,
|
|
175
|
+
label: 'Formation + contrôles',
|
|
176
|
+
errorReductionFactor: 0.28,
|
|
177
|
+
costBase: 45_000,
|
|
178
|
+
costPerTonne: 1.8,
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'wasto' as const,
|
|
182
|
+
label: 'Wast-O (app + IA + pilotage)',
|
|
183
|
+
errorReductionFactor: 0.45,
|
|
184
|
+
costBase: 18_000,
|
|
185
|
+
costPerTonne: 0.9,
|
|
186
|
+
},
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
export type ImpactConfidenceLevel = 1 | 2 | 3
|
|
190
|
+
|
|
191
|
+
export type ImpactConfidence = {
|
|
192
|
+
level: ImpactConfidenceLevel
|
|
193
|
+
label: string
|
|
194
|
+
tone: 'low' | 'medium' | 'high'
|
|
195
|
+
message: string
|
|
196
|
+
/** Facteur 0–1 appliqué à la réduction d'erreur pour le calcul. */
|
|
197
|
+
impactFactor: number
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type RoiActionInput = {
|
|
201
|
+
budgetCapexEur: number
|
|
202
|
+
budgetOpexEur: number
|
|
203
|
+
/** Réduction relative 0–100 (ex. 12 = −12 %). */
|
|
204
|
+
errorReductionPercent: number
|
|
205
|
+
status?: string
|
|
206
|
+
name?: string
|
|
207
|
+
id?: string
|
|
208
|
+
type?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type RoiEconomicsInput = {
|
|
212
|
+
annualTonnageOmr: number
|
|
213
|
+
errorRatePercent: number
|
|
214
|
+
costPerBadTonne: number
|
|
215
|
+
citeoSupportGainEur?: number
|
|
216
|
+
wastoAnnualCostEur?: number
|
|
217
|
+
/** Taux d'erreur baseline figé (sinon = errorRatePercent courant). */
|
|
218
|
+
baselineErrorRatePercent?: number | null
|
|
219
|
+
baselineAnnualTonnageOmr?: number | null
|
|
220
|
+
/** Si true, majore les soutiens Citeo (DOM). */
|
|
221
|
+
isDomTerritory?: boolean
|
|
222
|
+
fiscalYear?: number
|
|
223
|
+
tgapEurPerTonne?: number
|
|
224
|
+
/** Bilan annuel consolidé → peut monter l'ICI global à N3. */
|
|
225
|
+
annualBilanConfirmed?: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export type RoiLiveSignals = {
|
|
229
|
+
penetrationRatePercent: number
|
|
230
|
+
activeDevices30d: number
|
|
231
|
+
validatedVisionScans: number
|
|
232
|
+
challengeCompletions: number
|
|
233
|
+
terrainResponses: number
|
|
234
|
+
annualBilanConfirmed?: boolean
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type RoiIndicators = {
|
|
238
|
+
/** Gisement Économique Capturable (pertes avant). */
|
|
239
|
+
gecEur: number
|
|
240
|
+
baselineBadTonnes: number
|
|
241
|
+
baselineErrorRatePercent: number
|
|
242
|
+
afterErrorRatePercent: number
|
|
243
|
+
/** Réduction combinée effective en points de % d'erreur (ex. 5.6). */
|
|
244
|
+
combinedErrorReductionPoints: number
|
|
245
|
+
/** Réduction relative combinée (ex. 22.4 pour −22,4 %). */
|
|
246
|
+
combinedErrorReductionPercent: number
|
|
247
|
+
stackingFormula: string
|
|
248
|
+
afterAnnualLossEur: number
|
|
249
|
+
afterBadTonnes: number
|
|
250
|
+
/** Économies brutes = pertes avant − pertes après. */
|
|
251
|
+
grossSavingsEur: number
|
|
252
|
+
/** Budgets des seules actions (hors abonnement). */
|
|
253
|
+
actionsBudgetEur: number
|
|
254
|
+
/** Ligne abonnement / contrat Wast-O. */
|
|
255
|
+
wastoAnnualCostEur: number
|
|
256
|
+
totalActionBudgetsEur: number
|
|
257
|
+
citeoSupportGainEur: number
|
|
258
|
+
citeoSupportBaseEur: number
|
|
259
|
+
citeoDomMultiplier: number
|
|
260
|
+
isDomTerritory: boolean
|
|
261
|
+
/**
|
|
262
|
+
* CEN = économies brutes + Citeo − budgets cumulés
|
|
263
|
+
* (aligné avec le numérateur du ROI).
|
|
264
|
+
*/
|
|
265
|
+
cenEur: number
|
|
266
|
+
/** ROI = (économies + Citeo) / budgets ; null si budget 0. */
|
|
267
|
+
roiMultiple: number | null
|
|
268
|
+
overallConfidence: ImpactConfidence
|
|
269
|
+
disclaimer: string
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const ROI_DISCLAIMER =
|
|
273
|
+
'Ordres de grandeur pédagogiques fondés sur des hypothèses déclaratives (tonnages, taux d’erreur, budgets). Non contractuels — à affiner avec les données exploitant / SITOM / TGAP.'
|
|
274
|
+
|
|
275
|
+
function clamp(n: number, min: number, max: number): number {
|
|
276
|
+
return Math.min(max, Math.max(min, n))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function roundEuro(n: number): number {
|
|
280
|
+
return Math.round(n)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function isDomInseeCode(codeInsee: string | null | undefined): boolean {
|
|
284
|
+
if (!codeInsee) return false
|
|
285
|
+
const normalized = codeInsee.trim()
|
|
286
|
+
return DOM_INSEE_PREFIXES.some((p) => normalized.startsWith(p))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function detectDomTerritory(opts: {
|
|
290
|
+
cityInseeCodes?: Array<string | null | undefined>
|
|
291
|
+
cityGroupCode?: string | null
|
|
292
|
+
cityGroupName?: string | null
|
|
293
|
+
}): boolean {
|
|
294
|
+
if (opts.cityInseeCodes?.some((c) => isDomInseeCode(c))) return true
|
|
295
|
+
const code = (opts.cityGroupCode ?? '').toUpperCase()
|
|
296
|
+
const name = (opts.cityGroupName ?? '').toUpperCase()
|
|
297
|
+
if (name.includes('CINOR') || name.includes('REUNION') || name.includes('RÉUNION')) {
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
if (code.includes('974') || code.includes('CINOR')) return true
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function applyCiteoDomMultiplier(
|
|
305
|
+
baseCiteoEur: number,
|
|
306
|
+
isDom: boolean,
|
|
307
|
+
): { baseEur: number; multiplier: number; effectiveEur: number } {
|
|
308
|
+
const baseEur = roundEuro(baseCiteoEur)
|
|
309
|
+
const multiplier = isDom ? CITEO_DOM_MULTIPLIERS.compositeSupport : 1
|
|
310
|
+
return {
|
|
311
|
+
baseEur,
|
|
312
|
+
multiplier,
|
|
313
|
+
effectiveEur: roundEuro(baseEur * multiplier),
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** GEC = tonnage × (taux erreur / 100) × €/t */
|
|
318
|
+
export function computeGec(
|
|
319
|
+
annualTonnage: number,
|
|
320
|
+
errorRatePercent: number,
|
|
321
|
+
costPerBadTonne: number,
|
|
322
|
+
): { gecEur: number; badTonnes: number } {
|
|
323
|
+
const badTonnes = annualTonnage * (errorRatePercent / 100)
|
|
324
|
+
return {
|
|
325
|
+
badTonnes: Math.round(badTonnes * 10) / 10,
|
|
326
|
+
gecEur: roundEuro(badTonnes * costPerBadTonne),
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Empilement multiplicatif des réductions relatives d'actions actives.
|
|
332
|
+
* newRate = baseline × Π(1 − r_i/100)
|
|
333
|
+
*/
|
|
334
|
+
export function applyStackedErrorReductions(
|
|
335
|
+
baselineErrorRatePercent: number,
|
|
336
|
+
reductionsPercent: number[],
|
|
337
|
+
): number {
|
|
338
|
+
let rate = baselineErrorRatePercent
|
|
339
|
+
for (const r of reductionsPercent) {
|
|
340
|
+
const factor = clamp(r, 0, 95) / 100
|
|
341
|
+
rate = rate * (1 - factor)
|
|
342
|
+
}
|
|
343
|
+
return clamp(Math.round(rate * 10) / 10, 1, 99)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function isActionCountedInRoi(status?: string): boolean {
|
|
347
|
+
if (!status) return true
|
|
348
|
+
return status !== 'CANCELLED' && status !== 'PLANNED'
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* ICI — Indice de Confiance de l'Impact.
|
|
353
|
+
* 1 = déclaratif, 2 = proxy live, 3 = mesuré / représentatif.
|
|
354
|
+
*/
|
|
355
|
+
export function computeActionImpactConfidence(
|
|
356
|
+
action: RoiActionInput,
|
|
357
|
+
live: RoiLiveSignals,
|
|
358
|
+
): ImpactConfidence {
|
|
359
|
+
const type = (action.type ?? 'OTHER').toUpperCase()
|
|
360
|
+
const isApp = type === 'WASTO_APP'
|
|
361
|
+
const hasLiveDigital =
|
|
362
|
+
live.penetrationRatePercent > 0 ||
|
|
363
|
+
live.activeDevices30d > 0 ||
|
|
364
|
+
live.validatedVisionScans > 0 ||
|
|
365
|
+
live.challengeCompletions > 0
|
|
366
|
+
|
|
367
|
+
if (isApp && !hasLiveDigital) {
|
|
368
|
+
return {
|
|
369
|
+
level: 1,
|
|
370
|
+
label: 'Confiance faible',
|
|
371
|
+
tone: 'low',
|
|
372
|
+
message: 'Impact théorique (0 utilisateur actif)',
|
|
373
|
+
impactFactor: 0.25,
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (isApp && live.penetrationRatePercent < 2) {
|
|
378
|
+
return {
|
|
379
|
+
level: 1,
|
|
380
|
+
label: 'Confiance faible',
|
|
381
|
+
tone: 'low',
|
|
382
|
+
message: `Pénétration faible (${live.penetrationRatePercent} %) — impact bridé`,
|
|
383
|
+
impactFactor: 0.5,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (isApp && live.penetrationRatePercent >= 5) {
|
|
388
|
+
return {
|
|
389
|
+
level: 2,
|
|
390
|
+
label: 'Confiance moyenne',
|
|
391
|
+
tone: 'medium',
|
|
392
|
+
message: 'Proxy live app disponible',
|
|
393
|
+
impactFactor: 1,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (type === 'AWARENESS' || type === 'TRAINING' || type === 'CONTROLS') {
|
|
398
|
+
if (live.terrainResponses > 0) {
|
|
399
|
+
return {
|
|
400
|
+
level: 2,
|
|
401
|
+
label: 'Confiance moyenne',
|
|
402
|
+
tone: 'medium',
|
|
403
|
+
message: 'Appuyé par des données terrain',
|
|
404
|
+
impactFactor: 1,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
level: 1,
|
|
409
|
+
label: 'Confiance faible',
|
|
410
|
+
tone: 'low',
|
|
411
|
+
message: 'Impact déclaratif (pas encore de preuve mesurée)',
|
|
412
|
+
impactFactor: 0.85,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
level: 1,
|
|
418
|
+
label: 'Confiance faible',
|
|
419
|
+
tone: 'low',
|
|
420
|
+
message: 'Impact déclaratif',
|
|
421
|
+
impactFactor: 0.85,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function computeOverallConfidence(
|
|
426
|
+
actionConfidences: ImpactConfidence[],
|
|
427
|
+
opts?: { annualBilanConfirmed?: boolean },
|
|
428
|
+
): ImpactConfidence {
|
|
429
|
+
if (opts?.annualBilanConfirmed) {
|
|
430
|
+
return {
|
|
431
|
+
level: 3,
|
|
432
|
+
label: 'Confiance élevée',
|
|
433
|
+
tone: 'high',
|
|
434
|
+
message:
|
|
435
|
+
'Bilan annuel consolidé (TGAP / exploitant / coût du service) — niveau décideurs',
|
|
436
|
+
impactFactor: 1,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (actionConfidences.length === 0) {
|
|
440
|
+
return {
|
|
441
|
+
level: 1,
|
|
442
|
+
label: 'Confiance faible',
|
|
443
|
+
tone: 'low',
|
|
444
|
+
message: 'Aucune action active',
|
|
445
|
+
impactFactor: 1,
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const minLevel = Math.min(
|
|
449
|
+
...actionConfidences.map((c) => c.level),
|
|
450
|
+
) as ImpactConfidenceLevel
|
|
451
|
+
if (minLevel === 1) {
|
|
452
|
+
return {
|
|
453
|
+
level: 1,
|
|
454
|
+
label: 'Confiance faible',
|
|
455
|
+
tone: 'low',
|
|
456
|
+
message:
|
|
457
|
+
'ROI en partie théorique — au moins une action sans preuve live',
|
|
458
|
+
impactFactor: 1,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (minLevel === 2) {
|
|
462
|
+
return {
|
|
463
|
+
level: 2,
|
|
464
|
+
label: 'Confiance moyenne',
|
|
465
|
+
tone: 'medium',
|
|
466
|
+
message: 'ROI appuyé par des proxies live',
|
|
467
|
+
impactFactor: 1,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
level: 3,
|
|
472
|
+
label: 'Confiance élevée',
|
|
473
|
+
tone: 'high',
|
|
474
|
+
message: 'ROI adossé à des preuves représentatives',
|
|
475
|
+
impactFactor: 1,
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function computeRoiIndicators(
|
|
480
|
+
economics: RoiEconomicsInput,
|
|
481
|
+
actions: RoiActionInput[],
|
|
482
|
+
live?: RoiLiveSignals,
|
|
483
|
+
): RoiIndicators {
|
|
484
|
+
const baselineRate =
|
|
485
|
+
economics.baselineErrorRatePercent ?? economics.errorRatePercent
|
|
486
|
+
const baselineTonnage =
|
|
487
|
+
economics.baselineAnnualTonnageOmr ?? economics.annualTonnageOmr
|
|
488
|
+
const cost = economics.costPerBadTonne
|
|
489
|
+
const liveSignals: RoiLiveSignals = live ?? {
|
|
490
|
+
penetrationRatePercent: 0,
|
|
491
|
+
activeDevices30d: 0,
|
|
492
|
+
validatedVisionScans: 0,
|
|
493
|
+
challengeCompletions: 0,
|
|
494
|
+
terrainResponses: 0,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const before = computeGec(baselineTonnage, baselineRate, cost)
|
|
498
|
+
|
|
499
|
+
const counted = actions.filter((a) => isActionCountedInRoi(a.status))
|
|
500
|
+
const confidences = counted.map((a) =>
|
|
501
|
+
computeActionImpactConfidence(a, liveSignals),
|
|
502
|
+
)
|
|
503
|
+
const reductions = counted.map((a, i) => {
|
|
504
|
+
const factor = confidences[i]?.impactFactor ?? 1
|
|
505
|
+
return Math.max(0, a.errorReductionPercent) * factor
|
|
506
|
+
})
|
|
507
|
+
const afterRate =
|
|
508
|
+
reductions.length > 0
|
|
509
|
+
? applyStackedErrorReductions(baselineRate, reductions)
|
|
510
|
+
: economics.errorRatePercent
|
|
511
|
+
|
|
512
|
+
const combinedErrorReductionPoints =
|
|
513
|
+
Math.round((baselineRate - afterRate) * 10) / 10
|
|
514
|
+
const combinedErrorReductionPercent =
|
|
515
|
+
baselineRate > 0
|
|
516
|
+
? Math.round((1 - afterRate / baselineRate) * 1000) / 10
|
|
517
|
+
: 0
|
|
518
|
+
|
|
519
|
+
const after = computeGec(economics.annualTonnageOmr, afterRate, cost)
|
|
520
|
+
const grossSavingsEur = roundEuro(before.gecEur - after.gecEur)
|
|
521
|
+
|
|
522
|
+
const actionsBudgetEur = roundEuro(
|
|
523
|
+
counted.reduce(
|
|
524
|
+
(sum, a) => sum + (a.budgetCapexEur || 0) + (a.budgetOpexEur || 0),
|
|
525
|
+
0,
|
|
526
|
+
),
|
|
527
|
+
)
|
|
528
|
+
const wastoAnnualCostEur = roundEuro(economics.wastoAnnualCostEur ?? 0)
|
|
529
|
+
const totalActionBudgetsEur = roundEuro(actionsBudgetEur + wastoAnnualCostEur)
|
|
530
|
+
|
|
531
|
+
const isDomTerritory = economics.isDomTerritory === true
|
|
532
|
+
const citeo = applyCiteoDomMultiplier(
|
|
533
|
+
economics.citeoSupportGainEur ?? 0,
|
|
534
|
+
isDomTerritory,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
const cenEur = roundEuro(
|
|
538
|
+
grossSavingsEur + citeo.effectiveEur - totalActionBudgetsEur,
|
|
539
|
+
)
|
|
540
|
+
const roiMultiple =
|
|
541
|
+
totalActionBudgetsEur > 0
|
|
542
|
+
? Math.round(
|
|
543
|
+
((grossSavingsEur + citeo.effectiveEur) / totalActionBudgetsEur) *
|
|
544
|
+
10,
|
|
545
|
+
) / 10
|
|
546
|
+
: null
|
|
547
|
+
|
|
548
|
+
const overallConfidence = computeOverallConfidence(confidences, {
|
|
549
|
+
annualBilanConfirmed:
|
|
550
|
+
economics.annualBilanConfirmed === true ||
|
|
551
|
+
liveSignals.annualBilanConfirmed === true,
|
|
552
|
+
})
|
|
553
|
+
const stackingParts = reductions
|
|
554
|
+
.map((r) => `(1 − ${Math.round(r * 10) / 10}/100)`)
|
|
555
|
+
.join(' × ')
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
gecEur: before.gecEur,
|
|
559
|
+
baselineBadTonnes: before.badTonnes,
|
|
560
|
+
baselineErrorRatePercent: baselineRate,
|
|
561
|
+
afterErrorRatePercent: afterRate,
|
|
562
|
+
combinedErrorReductionPoints,
|
|
563
|
+
combinedErrorReductionPercent,
|
|
564
|
+
stackingFormula:
|
|
565
|
+
reductions.length > 0
|
|
566
|
+
? `taux_après = ${baselineRate} % × ${stackingParts}`
|
|
567
|
+
: 'Aucune réduction active',
|
|
568
|
+
afterAnnualLossEur: after.gecEur,
|
|
569
|
+
afterBadTonnes: after.badTonnes,
|
|
570
|
+
grossSavingsEur,
|
|
571
|
+
actionsBudgetEur,
|
|
572
|
+
wastoAnnualCostEur,
|
|
573
|
+
totalActionBudgetsEur,
|
|
574
|
+
citeoSupportGainEur: citeo.effectiveEur,
|
|
575
|
+
citeoSupportBaseEur: citeo.baseEur,
|
|
576
|
+
citeoDomMultiplier: citeo.multiplier,
|
|
577
|
+
isDomTerritory,
|
|
578
|
+
cenEur,
|
|
579
|
+
roiMultiple,
|
|
580
|
+
overallConfidence,
|
|
581
|
+
disclaimer: ROI_DISCLAIMER,
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export type ActionContribution = {
|
|
586
|
+
id?: string
|
|
587
|
+
name?: string
|
|
588
|
+
type?: string
|
|
589
|
+
shareEur: number
|
|
590
|
+
budgetEur: number
|
|
591
|
+
confidence: ImpactConfidence
|
|
592
|
+
effectiveReductionPercent: number
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** Contribution approximative au prorata de la réduction effective (après ICI). */
|
|
596
|
+
export function allocateSavingsByAction(
|
|
597
|
+
grossSavingsEur: number,
|
|
598
|
+
actions: RoiActionInput[],
|
|
599
|
+
live?: RoiLiveSignals,
|
|
600
|
+
): ActionContribution[] {
|
|
601
|
+
const liveSignals: RoiLiveSignals = live ?? {
|
|
602
|
+
penetrationRatePercent: 0,
|
|
603
|
+
activeDevices30d: 0,
|
|
604
|
+
validatedVisionScans: 0,
|
|
605
|
+
challengeCompletions: 0,
|
|
606
|
+
terrainResponses: 0,
|
|
607
|
+
}
|
|
608
|
+
const counted = actions.filter((a) => isActionCountedInRoi(a.status))
|
|
609
|
+
const enriched = counted.map((a) => {
|
|
610
|
+
const confidence = computeActionImpactConfidence(a, liveSignals)
|
|
611
|
+
return {
|
|
612
|
+
action: a,
|
|
613
|
+
confidence,
|
|
614
|
+
effectiveReductionPercent:
|
|
615
|
+
Math.max(0, a.errorReductionPercent) * confidence.impactFactor,
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
const totalReduction = enriched.reduce(
|
|
619
|
+
(s, e) => s + e.effectiveReductionPercent,
|
|
620
|
+
0,
|
|
621
|
+
)
|
|
622
|
+
if (totalReduction <= 0 || enriched.length === 0) {
|
|
623
|
+
return enriched.map((e) => ({
|
|
624
|
+
id: e.action.id,
|
|
625
|
+
name: e.action.name,
|
|
626
|
+
type: e.action.type,
|
|
627
|
+
shareEur: 0,
|
|
628
|
+
budgetEur: roundEuro(
|
|
629
|
+
(e.action.budgetCapexEur || 0) + (e.action.budgetOpexEur || 0),
|
|
630
|
+
),
|
|
631
|
+
confidence: e.confidence,
|
|
632
|
+
effectiveReductionPercent: e.effectiveReductionPercent,
|
|
633
|
+
}))
|
|
634
|
+
}
|
|
635
|
+
return enriched.map((e) => {
|
|
636
|
+
const weight = e.effectiveReductionPercent / totalReduction
|
|
637
|
+
return {
|
|
638
|
+
id: e.action.id,
|
|
639
|
+
name: e.action.name,
|
|
640
|
+
type: e.action.type,
|
|
641
|
+
shareEur: roundEuro(grossSavingsEur * weight),
|
|
642
|
+
budgetEur: roundEuro(
|
|
643
|
+
(e.action.budgetCapexEur || 0) + (e.action.budgetOpexEur || 0),
|
|
644
|
+
),
|
|
645
|
+
confidence: e.confidence,
|
|
646
|
+
effectiveReductionPercent:
|
|
647
|
+
Math.round(e.effectiveReductionPercent * 10) / 10,
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function estimateHouseholds(
|
|
653
|
+
householdCount: number | null | undefined,
|
|
654
|
+
population: number | null | undefined,
|
|
655
|
+
): number {
|
|
656
|
+
if (householdCount && householdCount > 0) return householdCount
|
|
657
|
+
if (population && population > 0) {
|
|
658
|
+
return Math.round(population / ROI_DEFAULTS.peoplePerHousehold)
|
|
659
|
+
}
|
|
660
|
+
return 0
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function computePenetrationRate(
|
|
664
|
+
activeHouseholdsProxy: number,
|
|
665
|
+
householdCount: number,
|
|
666
|
+
): number {
|
|
667
|
+
if (householdCount <= 0) return 0
|
|
668
|
+
return Math.round((activeHouseholdsProxy / householdCount) * 1000) / 10
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Simulation pédagogique territoire 100k hab. (validation formules). */
|
|
672
|
+
export function simulateTerritory100kExample() {
|
|
673
|
+
const economics: RoiEconomicsInput = {
|
|
674
|
+
annualTonnageOmr: 24_000,
|
|
675
|
+
errorRatePercent: 25,
|
|
676
|
+
costPerBadTonne: 300,
|
|
677
|
+
wastoAnnualCostEur: 18_000,
|
|
678
|
+
citeoSupportGainEur: 40_000,
|
|
679
|
+
baselineErrorRatePercent: 25,
|
|
680
|
+
baselineAnnualTonnageOmr: 24_000,
|
|
681
|
+
}
|
|
682
|
+
const actions: RoiActionInput[] = [
|
|
683
|
+
{
|
|
684
|
+
id: '1',
|
|
685
|
+
name: 'Campagne sensibilisation',
|
|
686
|
+
type: 'AWARENESS',
|
|
687
|
+
status: 'IN_PROGRESS',
|
|
688
|
+
budgetCapexEur: 0,
|
|
689
|
+
budgetOpexEur: 12_000,
|
|
690
|
+
errorReductionPercent: 12,
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
id: '2',
|
|
694
|
+
name: 'Contrôles de bac',
|
|
695
|
+
type: 'CONTROLS',
|
|
696
|
+
status: 'IN_PROGRESS',
|
|
697
|
+
budgetCapexEur: 5_000,
|
|
698
|
+
budgetOpexEur: 35_000,
|
|
699
|
+
errorReductionPercent: 18,
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
id: '3',
|
|
703
|
+
name: 'Wast-O app',
|
|
704
|
+
type: 'WASTO_APP',
|
|
705
|
+
status: 'IN_PROGRESS',
|
|
706
|
+
budgetCapexEur: 0,
|
|
707
|
+
budgetOpexEur: 0,
|
|
708
|
+
errorReductionPercent: 20,
|
|
709
|
+
},
|
|
710
|
+
]
|
|
711
|
+
const live: RoiLiveSignals = {
|
|
712
|
+
penetrationRatePercent: 3,
|
|
713
|
+
activeDevices30d: 1200,
|
|
714
|
+
validatedVisionScans: 400,
|
|
715
|
+
challengeCompletions: 80,
|
|
716
|
+
terrainResponses: 50,
|
|
717
|
+
}
|
|
718
|
+
const indicators = computeRoiIndicators(economics, actions, live)
|
|
719
|
+
const households = estimateHouseholds(null, 100_000)
|
|
720
|
+
return {
|
|
721
|
+
population: 100_000,
|
|
722
|
+
households,
|
|
723
|
+
economics,
|
|
724
|
+
actions,
|
|
725
|
+
indicators,
|
|
726
|
+
contributions: allocateSavingsByAction(
|
|
727
|
+
indicators.grossSavingsEur,
|
|
728
|
+
actions,
|
|
729
|
+
live,
|
|
730
|
+
),
|
|
731
|
+
}
|
|
732
|
+
}
|