2234 .filter((rule) => clean(rule.scope_type || "global").toLowerCase() === "global" || clean(rule.scope_id) === clean(centreId)) 2235 .sort((left, right) => scopeRank(right.scope_type) - scopeRank(left.scope_type))[0] || null; 2236} 2237 2238function monthlyFormulaConfig(rule) { 2239 const parsed = jsonObject(rule?.tiers_json); 2240 const transformationNumerator = clean(parsed.transformation_numerator).toLowerCase(); 2241 const opportunityBasis = clean(parsed.opportunity_basis).toLowerCase(); 2242 return { 2243 enabled: parsed.enabled !== false, 2244 label: clean(parsed.label) || "Rémunération Énergie partenaires", 2245 period_basis: clean(parsed.period_basis) === "calendar_month" ? "calendar_month" : "stats_period", 2246 base_amount: finiteNumber(parsed.base_amount, cents(rule?.revenue_cents)), 2247 eligibility_metric: formulaMetric(parsed.eligibility_metric, "transformation_rate"), 2248 eligibility_operator: formulaOperator(parsed.eligibility_operator, "gte"), 2249 eligibility_threshold: finiteNumber(parsed.eligibility_threshold, 0), 2250 ineligible_amount: finiteNumber(parsed.ineligible_amount, 0), 2251 minimum_amount: finiteNumber(parsed.minimum_amount, 0), 2252 maximum_amount: finiteNumber(parsed.maximum_amount, 100_000), 2253 include_additional_revenue_in_ca: parsed.include_additional_revenue_in_ca === true, 2254 transformation_numerator: transformationNumerator === "energy_contracts" ? "energy_contracts" : "main_sales", 2255 opportunity_basis: ["unique_inbound", "assigned_prospects", "routed_work_items", "mixed"].includes(opportunityBasis) 2256 ? opportunityBasis 2257 : "", 2258 opportunity_sources: unique(array(parsed.opportunity_sources).map((value) => clean(value).toLowerCase()).filter(Boolean)), 2259 adjustments: array(parsed.adjustments).map((adjustment, index) => normalizeFormulaAdjustment(adjustment, index)), 2260 sale_rules: array(parsed.sale_rules).map((saleRule, index) => normalizeSaleRule(saleRule, index)), 2261 }; 2262} 2263 2264export function evaluateMonthlyFormula(formula, performance = {}) { 2265 const eligible = formulaConditionMatches( 2266 finiteNumber(performance[formula.eligibility_metric], 0), 2267 formula.eligibility_operator, 2268 formula.eligibility_threshold, 2269 ); 2270 const breakdown = [{ 2271 id: "base", 2272 label: formula.label || "Base mensuelle", 2273 metric: formula.eligibility_metric, 2274 value: finiteNumber(performance[formula.eligibility_metric], 0), 2275 threshold: formula.eligibility_threshold, 2276 operator: formula.eligibility_operator, 2277 delta: eligible ? formula.base_amount : formula.ineligible_amount, 2278 applied: eligible, 2279 }]; 2280 let amount = eligible ? formula.base_amount : formula.ineligible_amount; 2281 if (eligible) { 2282 const adjustments = array(formula.adjustments).map((adjustment, index) => normalizeFormulaAdjustment(adjustment, index)); 2283 const matched = new Set(adjustments 2284 .map((adjustment, index) => ({ adjustment, index })) 2285 .filter(({ adjustment }) => adjustment.enabled !== false && adjustment.conditions.every((condition) => ( 2286 formulaConditionMatches(finiteNumber(performance[condition.metric], 0), condition.operator, condition.threshold) 2287 ))) 2288 .map(({ index }) => index)); 2289 const selectedByGroup = new Map(); 2290 for (const index of matched) { 2291 const adjustment = adjustments[index]; 2292 if (!adjustment.exclusive_group) continue; 2293 const current = selectedByGroup.get(adjustment.exclusive_group); 2294 if (current == null || adjustment.priority > adjustments[current].priority) { 2295 selectedByGroup.set(adjustment.exclusive_group, index); 2296 } 2297 } 2298 for (let index = 0; index < adjustments.length; index += 1) { 2299 const adjustment = adjustments[index]; 2300 if (adjustment.enabled === false) continue; 2301 const isMatch = matched.has(index); 2302 const applied = isMatch && (!adjustment.exclusive_group || selectedByGroup.get(adjustment.exclusive_group) === index); 2303 const conditionValues = adjustment.conditions.map((condition) => ({ 2304 ...condition, 2305 value: finiteNumber(performance[condition.metric], 0), 2306 })); 2307 breakdown.push({ 2308 ...adjustment, 2309 value: conditionValues[0]?.value ?? 0, 2310 condition_values: conditionValues, 2311 matched: isMatch, 2312 applied, 2313 skipped_reason: isMatch && !applied ? "exclusive_group" : null, 2314 }); 2315 if (!applied) continue; 2316 const effectValue = finiteNumber(adjustment.effect_value, 0); 2317 if (adjustment.effect === "set") amount = effectValue; 2318 else if (adjustment.effect === "cap") amount = Math.min(amount, effectValue); 2319 else if (adjustment.effect === "floor") amount = Math.max(amount, effectValue); 2320 else if (adjustment.effect !== "none") amount += effectValue; 2321 } 2322 } 2323 const minimum = finiteNumber(formula.minimum_amount, 0); 2324 const maximum = Math.max(minimum, finiteNumber(formula.maximum_amount, 100_000)); 2325 return { 2326 amount: Math.min(maximum, Math.max(minimum, Math.round(amount * 100) / 100)), 2327 eligible, 2328 breakdown, 2329 }; 2330} 2331 2332function normalizeFormulaAdjustment(adjustment, index) { 2333 const metric = formulaMetric(adjustment?.metric, "attachment_rate"); 2334 const operator = formulaOperator(adjustment?.operator, "gte"); 2335 const threshold = finiteNumber(adjustment?.threshold, 0); 2336 const effect = ["add", "set", "cap", "floor", "none"].includes(clean(adjustment?.effect).toLowerCase()) 2337 ? clean(adjustment.effect).toLowerCase() 2338 : "add"; 2339 const delta = finiteNumber(adjustment?.delta, 0); 2340 const conditions = array(adjustment?.conditions).length 2341 ? array(adjustment.conditions).slice(0, 8).map((condition) => ({ 2342 metric: formulaMetric(condition?.metric, metric), 2343 operator: formulaOperator(condition?.operator, "gte"), 2344 threshold: finiteNumber(condition?.threshold, 0), 2345 })) 2346 : [{ metric, operator, threshold }]; 2347 return { 2348 id: clean(adjustment?.id) || `adjustment-${index + 1}`, 2349 label: clean(adjustment?.label) || `Ajustement ${index + 1}`, 2350 metric, 2351 operator, 2352 threshold, 2353 conditions, 2354 effect, 2355 effect_value: finiteNumber(adjustment?.value, finiteNumber(adjustment?.effect_value, effect === "add" ? delta : 0)), 2356 delta, 2357 exclusive_group: clean(adjustment?.exclusive_group) || null, 2358 priority: finiteNumber(adjustment?.priority, index), 2359 enabled: adjustment?.enabled !== false, 2360 }; 2361} 2362 2363function normalizeSaleRule(rule, index) { 2364 return { 2365 id: clean(rule?.id) || `sale-rule-${index + 1}`, 2366 label: clean(rule?.label) || `Règle vente ${index + 1}`, 2367 conditions: array(rule?.conditions).slice(0, 8).map((condition) => ({ 2368 metric: clean(condition?.metric).toLowerCase() === "ai_score" ? "ai_score" : "ai_score", 2369 operator: formulaOperator(condition?.operator, "gte"), 2370 threshold: finiteNumber(condition?.threshold, 0), 2371 })), 2372 effect: ["exclude", "multiply", "set"].includes(clean(rule?.effect).toLowerCase()) ? clean(rule.effect).toLowerCase() : "exclude", 2373 value: finiteNumber(rule?.value, clean(rule?.effect).toLowerCase() === "multiply" ? 1 : 0), 2374 missing: clean(rule?.missing).toLowerCase() === "match" ? "match" : "ignore", 2375 enabled: rule?.enabled !== false, 2376 }; 2377} 2378 2379function latestReportScoreIndex(reports) { 2380 const latest = new Map(); 2381 const remember = (key, modified, score) => { 2382 if (!key) return; 2383 const current = latest.get(key); 2384 if (!current || modified >= current.modified) latest.set(key, { modified, score }); 2385 }; 2386 for (const report of array(reports)) { 2387 if (Number(report.aggregate_score) === 1) continue; 2388 const maximum = finiteNumber(report.score_max, 0); 2389 const total = finiteNumber(report.score_total, NaN); 2390 if (!maximum || !Number.isFinite(total)) continue; 2391 const score = total / maximum * 100; 2392 const modified = clean(report.modified_time); 2393 const saleIds = jsonValues(report.vente_ids); 2394 for (const id of saleIds) remember(`sale:${id}`, modified, score); 2395 if (!saleIds.length && clean(report.appel_id)) remember(`call:${clean(report.appel_id)}`, modified, score); 2396 } 2397 return latest; 2398} 2399 2400function reportScoreForSale(sale, reportScoreIndex) { 2401 for (const id of [sale?.id, sale?.local_id].map(clean).filter(Boolean)) { 2402 const entry = reportScoreIndex.get(`sale:${id}`); 2403 if (entry) return entry.score; 2404 } 2405 for (const id of [sale?.appel_id, sale?.appel_source_id].map(clean).filter(Boolean)) { 2406 const entry = reportScoreIndex.get(`call:${id}`); 2407 if (entry) return entry.score; 2408 } 2409 return null; 2410} 2411 2412function saleRuleMatches(rule, score) { 2413 if (!array(rule?.conditions).length) return false; 2414 return rule.conditions.every((condition) => { 2415 if (condition.metric !== "ai_score") return false; 2416 if (!Number.isFinite(Number(score))) return rule.missing === "match"; 2417 return formulaConditionMatches(Number(score), condition.operator, condition.threshold); 2418 }); 2419} 2420 2421function evaluateSaleRemuneration(saleRules, score, baseAmount) { 2422 let amount = finiteNumber(baseAmount, 0); 2423 let excluded = false; 2424 const breakdown = []; 2425 for (const rule of array(saleRules)) { 2426 if (rule?.enabled === false) continue; 2427 const applied = saleRuleMatches(rule, score); 2428 breakdown.push({ ...rule, score: Number.isFinite(Number(score)) ? Number(score) : null, applied }); 2429 if (!applied) continue; 2430 if (rule.effect === "exclude") { 2431 amount = 0; 2432 excluded = true; 2433 } else if (rule.effect === "multiply") { 2434 amount *= finiteNumber(rule.value, 1); 2435 } else if (rule.effect === "set") { 2436 amount = finiteNumber(rule.value, 0); 2437 } 2438 } 2439 return { amount: Math.max(0, Math.round(amount * 100) / 100), excluded, breakdown }; 2440} 2441 2442function saleRuleMetricsForPeriod({ revenueEntries, reportScoreIndex, centreId, period, saleRules }) { 2443 let energyContracts = 0; 2444 let remuneratedEnergyContracts = 0; 2445 let excludedEnergyContracts = 0; 2446 let unscoredEnergyContracts = 0; 2447 for (const entry of array(revenueEntries)) { 2448 if (!entry.energyContract || entry.row.centreId !== centreId || !dateInRange(entry.row.date, period.dateFrom, period.dateTo)) continue; 2449 const count = Number.isFinite(Number(entry.factCount)) ? nonNegative(entry.factCount) : 1; 2450 energyContracts += count; 2451 const score = reportScoreForSale(entry.sale, reportScoreIndex); 2452 if (!Number.isFinite(Number(score))) unscoredEnergyContracts += count; 2453 const evaluation = evaluateSaleRemuneration(saleRules, score, 1); 2454 if (evaluation.excluded) excludedEnergyContracts += count; 2455 else remuneratedEnergyContracts += count; 2456 } 2457 return { energyContracts, remuneratedEnergyContracts, excludedEnergyContracts, unscoredEnergyContracts }; 2458} 2459 2460function saleRemunerationForEntry(entry, monthly, reportScoreIndex) { 2461 const score = reportScoreForSale(entry.sale, reportScoreIndex); 2462 const evaluation = evaluateSaleRemuneration(monthly.saleRules, score, monthly.amount); 2463 const count = Number.isFinite(Number(entry.factCount)) ? nonNegative(entry.factCount) : 1; 2464 return { 2465 total: evaluation.amount * count, 2466 remuneratedCount: evaluation.excluded ? 0 : count, 2467 excludedCount: evaluation.excluded ? count : 0, 2468 }; 2469} 2470 2471function formulaConditionMatches(value, operator, threshold) { 2472 const target = finiteNumber(threshold, 0); 2473 if (operator === "gt") return value > target; 2474 if (operator === "lte") return value <= target; 2475 if (operator === "lt") return value < target; 2476 return value >= target; 2477} 2478 2479function formulaMetric(value, fallback) { 2480 const metric = clean(value).toLowerCase(); 2481 return ["transformation_rate", "attachment_rate", "meteore_attachment_rate", "consopilote_attachment_rate", "average_ai_score"].includes(metric) 2482 ? metric 2483 : fallback; 2484} 2485 2486function formulaOperator(value, fallback) { 2487 const operator = clean(value).toLowerCase(); 2488 return ["gte", "gt", "lte", "lt"].includes(operator) ? operator : fallback; 2489} 2490 2491function remunerationCoverage({ rules, statsPeriods, centreIds, dateFrom, dateTo }) { 2492 let from = dateFrom; 2493 let to = dateTo; 2494 for (const centreId of centreIds) { 2495 for (const date of dateSequence(dateFrom, dateTo)) { 2496 const rule = monthlyFormulaRuleFor(rules, centreId, date); 2497 if (!rule) continue; 2498 const formula = monthlyFormulaConfig(rule); 2499 if (!formula.enabled) continue; 2500 const period = remunerationPeriodForDate(date, centreId, statsPeriods, formula.period_basis); 2501 if (period.dateFrom < from) from = period.dateFrom; 2502 if (period.dateTo > to) to = period.dateTo; 2503 } 2504 } 2505 return { dateFrom: from, dateTo: to }; 2506} 2507 2508function remunerationPeriodForDate(date, centreId, statsPeriods, basis) { 2509 if (basis === "stats_period") { 2510 const selected = array(statsPeriods) 2511 .filter(activeRow) 2512 .filter((period) => !clean(period.archived_at_iso)) 2513 .filter((period) => dateInRange(date, dateOnly(period.starts_at), dateOnly(period.ends_at))) 2514 .filter((period) => clean(period.scope_type || "global").toLowerCase() === "global" || clean(period.scope_id) === clean(centreId)) 2515 .sort((left, right) => scopeRank(right.scope_type) - scopeRank(left.scope_type))[0]; 2516 if (selected) { 2517 return { 2518 id: clean(selected.local_id || selected.id), 2519 label: clean(selected.label) || "Période statistique", 2520 dateFrom: dateOnly(selected.starts_at), 2521 dateTo: dateOnly(selected.ends_at), 2522 }; 2523 } 2524 } 2525 const start = `${date.slice(0, 7)}-01`; 2526 const endDate = new Date(`${start}T12:00:00Z`); 2527 endDate.setUTCMonth(endDate.getUTCMonth() + 1); 2528 endDate.setUTCDate(0); 2529 const end = endDate.toISOString().slice(0, 10); 2530 return { 2531 id: `calendar:${date.slice(0, 7)}`, 2532 label: MONTH_LABEL_FORMATTER.format(new Date(`${start}T12:00:00Z`)), 2533 dateFrom: start, 2534 dateTo: end, 2535 }; 2536} 2537 2538function performanceForPeriod({ rows, reports, saleById, centreId, period, operationalCentreIndex, formula }) { 2539 const entries = rows.filter((row) => row.centreId === centreId && dateInRange(row.date, period.dateFrom, period.dateTo)); 2540 const received = sum(entries, "received"); 2541 const mainSales = sum(entries, "mainSales"); 2542 const energyContracts = sum(entries, "energyContracts"); 2543 const transformationNumerator = formula?.transformation_numerator === "energy_contracts" ? energyContracts : mainSales; 2544 const denominator = energyContracts || mainSales; 2545 const aggregateScores = reports.filter((report) => Number(report.aggregate_score) === 1) 2546 .filter((report) => { 2547 const rawCentre = clean(report.centre_appel_id); 2548 const reportCentre = operationalCentreIndex.canonicalByAlias.get(rawCentre) || rawCentre; 2549 return reportCentre === centreId && dateInRange(clean(report.score_date), period.dateFrom, period.dateTo); 2550 }); 2551 const aggregateScoreCount = sum(aggregateScores, "score_count"); 2552 const aggregateScoreSum = sum(aggregateScores, "score_sum"); 2553 const scores = aggregateScores.length ? [] : latestReportScoresForPeriod({ 2554 reports, 2555 saleById, 2556 centreId, 2557 period, 2558 operationalCentreIndex, 2559 }); 2560 return { 2561 transformation_rate: received ? transformationNumerator / received * 100 : 0, 2562 attachment_rate: denominator ? sum(entries, "attachedMainSales") / denominator * 100 : 0, 2563 meteore_attachment_rate: denominator ? sum(entries, "meteoreAttachedMainSales") / denominator * 100 : 0, 2564 consopilote_attachment_rate: denominator ? sum(entries, "consoPiloteAttachedMainSales") / denominator * 100 : 0, 2565 average_ai_score: aggregateScoreCount ? aggregateScoreSum / aggregateScoreCount : scores.length ? scores.reduce((total, score) => total + score, 0) / scores.length : 0, 2566 received, 2567 mainSales, 2568 energyContracts, 2569 transformation_numerator: formula?.transformation_numerator || "main_sales", 2570 transformation_numerator_count: transformationNumerator, 2571 opportunity_basis: formula?.opportunity_basis || "", 2572 opportunity_sources: formula?.opportunity_sources || [], 2573 scoredSales: aggregateScoreCount || scores.length, 2574 }; 2575} 2576 2577function latestReportScoresForPeriod({ reports, saleById, centreId, period, operationalCentreIndex }) { 2578 const latest = new Map(); 2579 for (const report of reports) { 2580 const scoreMax = finiteNumber(report.score_max, 0); 2581 const scoreTotal = finiteNumber(report.score_total, NaN); 2582 if (!scoreMax || !Number.isFinite(scoreTotal)) continue; 2583 const saleIds = jsonValues(report.vente_ids); 2584 const linkedSale = saleIds.map((id) => saleById.get(clean(id))).find(Boolean); 2585 const rawCentre = clean(report.centre_appel_id) || clean(linkedSale?.centre_appel_id); 2586 const reportCentre = operationalCentreIndex.canonicalByAlias.get(rawCentre) || rawCentre; 2587 if (reportCentre !== centreId) continue; 2588 const date = linkedSale ? saleDateOnly(linkedSale) : dateOnly(report.modified_time); 2589 if (!dateInRange(date, period.dateFrom, period.dateTo)) continue; 2590 const key = saleIds[0] || clean(report.appel_id) || clean(report.id);