export type RfmThresholdRowLike = { kind: 'r' | 'f' | string score: number min_value: number max_value: number } export type RfmRecencyDefinition = { within_days: number exclude_within_days?: number | null } export type RfmFrequencyDefinition = { op: string value: number value2?: number | null } export type RfmSegmentDefinition = { segment_key: string segment_name: string window_days: number recency: RfmRecencyDefinition frequency: RfmFrequencyDefinition criteria: any } type ScoreRange = { minScore: number; maxScore: number } export class RfmSegmentBuilder { static getSegmentScoreRanges(segmentKey: string): { r: ScoreRange; f: ScoreRange } | null { const k = String(segmentKey || '').trim() switch (k) { case 'champions': return { r: { minScore: 4, maxScore: 5 }, f: { minScore: 4, maxScore: 5 } } case 'loyal_users': return { r: { minScore: 3, maxScore: 3 }, f: { minScore: 4, maxScore: 5 } } case 'potential_loyalists': return { r: { minScore: 4, maxScore: 5 }, f: { minScore: 2, maxScore: 3 } } case 'new_users': return { r: { minScore: 5, maxScore: 5 }, f: { minScore: 1, maxScore: 1 } } case 'promising': return { r: { minScore: 4, maxScore: 4 }, f: { minScore: 1, maxScore: 1 } } case 'needing_attention': return { r: { minScore: 3, maxScore: 3 }, f: { minScore: 3, maxScore: 3 } } case 'about_to_sleep': return { r: { minScore: 3, maxScore: 3 }, f: { minScore: 1, maxScore: 2 } } case 'cannot_lose_them': return { r: { minScore: 1, maxScore: 2 }, f: { minScore: 5, maxScore: 5 } } case 'at_risk': return { r: { minScore: 1, maxScore: 2 }, f: { minScore: 3, maxScore: 4 } } case 'hibernating': return { r: { minScore: 1, maxScore: 2 }, f: { minScore: 1, maxScore: 2 } } default: return null } } static getSegmentName(segmentKey: string): string { const map: Record = { champions: 'Champions', loyal_users: 'Loyal Users', potential_loyalists: 'Potential Loyalists', new_users: 'New Users', promising: 'Promising', needing_attention: 'Needing Attention', about_to_sleep: 'About to Sleep', at_risk: 'At Risk', cannot_lose_them: 'Cannot Lose Them', hibernating: 'Hibernating', } return map[String(segmentKey || '').trim()] || String(segmentKey || '').trim() || 'Segment' } static clampInt(n: number, min: number, max: number): number { const x = Number.isFinite(n) ? Math.trunc(n) : min return Math.max(min, Math.min(max, x)) } static buildDefinition(params: { rfEventName: string windowDays: number segmentKey: string thresholds: RfmThresholdRowLike[] filterCriteria?: any | null }): RfmSegmentDefinition { const { rfEventName, windowDays, segmentKey, thresholds, filterCriteria } = params const ranges = this.getSegmentScoreRanges(segmentKey) if (!ranges) throw new Error('segment_key inválido') const rMaxDays = new Map() const fMinCount = new Map() const fMaxCount = new Map() for (const row of thresholds || []) { const score = Number(row.score) if (!Number.isFinite(score)) continue if (row.kind === 'r') { const maxV = Number(row.max_value) if (Number.isFinite(maxV)) rMaxDays.set(score, Math.max(0, Math.trunc(maxV))) } else if (row.kind === 'f') { const minV = Number(row.min_value) const maxV = Number(row.max_value) if (Number.isFinite(minV)) fMinCount.set(score, Math.max(0, Math.trunc(minV))) if (Number.isFinite(maxV)) fMaxCount.set(score, Math.max(0, Math.trunc(maxV))) } } // Recency bounds const rMin = ranges.r.minScore const rMax = ranges.r.maxScore const recencyUpperRaw = (() => { // Para os piores scores (1..2), a regra de recência é dominada pelo "NOT within", // então o upper bound pode ser a própria janela. if (rMin <= 1) return windowDays // Para ranges como 4..5, pode acontecer de um score intermediário não existir // (por empates/quantis). Nesse caso, usar o primeiro score disponível no range. for (let s = rMin; s <= rMax; s++) { const v = rMaxDays.get(s) if (v != null) return v } throw new Error(`Sem thresholds para recency score ${rMin}..${rMax}`) })() const recencyUpper = this.clampInt(recencyUpperRaw, 1, Math.max(1, windowDays)) const recencyLowerRaw = (() => { if (rMax >= 5) return null for (let s = rMax + 1; s <= 5; s++) { const v = rMaxDays.get(s) if (v != null) return v } return null })() const recencyLower = recencyLowerRaw == null ? null : this.clampInt(recencyLowerRaw, 1, Math.max(1, windowDays)) // Frequency bounds const fMin = ranges.f.minScore const fMax = ranges.f.maxScore let hasAnyFreqThreshold = false for (let s = fMin; s <= fMax; s++) { if (fMinCount.has(s) || fMaxCount.has(s)) { hasAnyFreqThreshold = true break } } if (!hasAnyFreqThreshold) { throw new Error(`Sem thresholds para frequency score ${fMin}..${fMax}`) } const collectMin = () => { let min = Number.POSITIVE_INFINITY for (let s = fMin; s <= fMax; s++) { const v = fMinCount.get(s) ?? fMaxCount.get(s) if (v == null) continue if (v < min) min = v } if (!Number.isFinite(min)) { throw new Error(`Sem thresholds mínimos para frequency score ${fMin}..${fMax}`) } return min } const collectMax = () => { let max = 0 for (let s = fMin; s <= fMax; s++) { const v = fMaxCount.get(s) ?? fMinCount.get(s) if (v == null) continue if (v > max) max = v } if (!(max > 0)) { throw new Error(`Sem thresholds máximos para frequency score ${fMin}..${fMax}`) } return max } const minCount = Math.max(1, Math.trunc(collectMin())) const maxCount = Math.max(minCount, Math.trunc(collectMax())) const frequency: { op: string; value: number; value2: number | null } = (() => { if (fMax === 5 && fMin >= 4) return { op: '>=', value: minCount, value2: null } if (fMin === 5 && fMax === 5) return { op: '>=', value: minCount, value2: null } if (fMin === 1 && fMax <= 2) return { op: '<=', value: maxCount, value2: null } if (fMin === 1 && fMax === 1) return { op: '<=', value: maxCount, value2: null } return { op: 'between', value: minCount, value2: maxCount } })() const rfmGroup: any = { operator: 'AND', rules: [ // Frequency bucket in the analysis window { kind: 'event', eventName: rfEventName, time: { unit: 'days', value: windowDays }, frequency: { type: 'count', op: frequency.op, value: frequency.value, ...(frequency.op === 'between' ? { value2: frequency.value2 } : {}), }, }, // Recency upper bound (must have done within this window) { kind: 'event', eventName: rfEventName, time: { unit: 'days', value: recencyUpper }, }, // Recency lower bound (exclude too-recent users) ...(recencyLower ? [ { kind: 'event', eventName: rfEventName, time: { unit: 'days', value: recencyLower }, negate: true, }, ] : []), ], } const filterGroupsRaw = filterCriteria && typeof filterCriteria === 'object' && Array.isArray((filterCriteria as any).groups) ? (filterCriteria as any).groups : [] const mergedGroups = (() => { const base = Array.isArray(filterGroupsRaw) ? [...filterGroupsRaw] : [] if (base.length === 0) return [rfmGroup] return [...base, { ...rfmGroup, combineOperator: 'AND' }] })() const baseConfig = filterCriteria && typeof filterCriteria === 'object' && (filterCriteria as any).config && typeof (filterCriteria as any).config === 'object' ? (filterCriteria as any).config : {} const criteria = { type: 'past-behavior', groups: mergedGroups, config: { ...baseConfig, rfm: { segment_key: segmentKey, rf_event_name: rfEventName, window_days: windowDays, recency: { within_days: recencyUpper, exclude_within_days: recencyLower }, frequency: { ...frequency }, }, }, ...( filterCriteria && typeof filterCriteria === 'object' && (filterCriteria as any).live_options ? { live_options: (filterCriteria as any).live_options } : {} ), } return { segment_key: segmentKey, segment_name: this.getSegmentName(segmentKey), window_days: windowDays, recency: { within_days: recencyUpper, exclude_within_days: recencyLower }, frequency: { op: frequency.op, value: frequency.value, value2: frequency.value2 ?? null }, criteria, } } }