@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.
Files changed (56) hide show
  1. package/dist/index.d.mts +17 -1
  2. package/dist/index.d.ts +17 -1
  3. package/dist/index.js +7 -0
  4. package/dist/index.mjs +7 -0
  5. package/package.json +1 -1
  6. package/src/client.ts +701 -692
  7. package/src/dashboard-apps.ts +350 -350
  8. package/src/dto/cms-app-settings.ts +91 -91
  9. package/src/enums.ts +298 -298
  10. package/src/free-tier.ts +56 -56
  11. package/src/index.ts +35 -35
  12. package/src/opening-hours/constants.ts +48 -48
  13. package/src/opening-hours/convert.ts +276 -276
  14. package/src/opening-hours/format.ts +99 -99
  15. package/src/opening-hours/index.ts +9 -9
  16. package/src/opening-hours/is-open-now.ts +38 -38
  17. package/src/opening-hours/resolve.ts +71 -71
  18. package/src/opening-hours/time.ts +74 -74
  19. package/src/opening-hours/timezone.ts +34 -34
  20. package/src/opening-hours/types.ts +53 -53
  21. package/src/opening-hours/validate.ts +182 -182
  22. package/src/roi/calculations.ts +732 -732
  23. package/src/roi/index.ts +1 -1
  24. package/src/types/app-preload.types.ts +10 -0
  25. package/src/types/asset-bookings.types.ts +19 -19
  26. package/src/types/asset-interventions.types.ts +24 -24
  27. package/src/types/assets.types.ts +68 -68
  28. package/src/types/auth.types.ts +37 -37
  29. package/src/types/blog-posts.types.ts +19 -19
  30. package/src/types/calendar-pdf.types.ts +24 -24
  31. package/src/types/calendo-events.types.ts +64 -64
  32. package/src/types/challenges.types.ts +30 -30
  33. package/src/types/chatbot.types.ts +82 -82
  34. package/src/types/cities.types.ts +27 -27
  35. package/src/types/city-groups.types.ts +46 -46
  36. package/src/types/cms.types.ts +87 -87
  37. package/src/types/field-reports.types.ts +66 -66
  38. package/src/types/free-tier.types.ts +54 -54
  39. package/src/types/index.ts +33 -32
  40. package/src/types/iot.types.ts +31 -31
  41. package/src/types/maintenance.types.ts +44 -44
  42. package/src/types/mapso.types.ts +146 -146
  43. package/src/types/notifications.types.ts +68 -68
  44. package/src/types/onboarding.types.ts +117 -117
  45. package/src/types/operational-zones.types.ts +70 -70
  46. package/src/types/pagination.types.ts +33 -33
  47. package/src/types/providers.types.ts +29 -29
  48. package/src/types/quiz-questions.types.ts +59 -59
  49. package/src/types/recycling-points.types.ts +47 -47
  50. package/src/types/roi.types.ts +60 -60
  51. package/src/types/surveys.types.ts +103 -103
  52. package/src/types/tickets.types.ts +65 -65
  53. package/src/types/users.types.ts +30 -30
  54. package/src/types/waste-collection-places-query.types.ts +4 -4
  55. package/src/types/waste-collection-places.types.ts +33 -33
  56. package/src/types/waste-types.types.ts +21 -21
@@ -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
+ }