@reachy/audience-module 1.0.17 → 1.0.19
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/CLAUDE.md +134 -0
- package/dist/AudienceModule.d.ts.map +1 -1
- package/dist/AudienceModule.js +1 -0
- package/dist/AudienceModule.js.map +1 -1
- package/dist/builders/RfmSegmentBuilder.d.ts +44 -0
- package/dist/builders/RfmSegmentBuilder.d.ts.map +1 -0
- package/dist/builders/RfmSegmentBuilder.js +232 -0
- package/dist/builders/RfmSegmentBuilder.js.map +1 -0
- package/dist/engine/RfmEngine.d.ts +67 -0
- package/dist/engine/RfmEngine.d.ts.map +1 -0
- package/dist/engine/RfmEngine.js +335 -0
- package/dist/engine/RfmEngine.js.map +1 -0
- package/dist/engine/V2AudienceEngine.d.ts +5 -0
- package/dist/engine/V2AudienceEngine.d.ts.map +1 -1
- package/dist/engine/V2AudienceEngine.js +210 -72
- package/dist/engine/V2AudienceEngine.js.map +1 -1
- package/dist/executors/ClickHouseEventQueryExecutor.d.ts +23 -0
- package/dist/executors/ClickHouseEventQueryExecutor.d.ts.map +1 -0
- package/dist/executors/ClickHouseEventQueryExecutor.js +803 -0
- package/dist/executors/ClickHouseEventQueryExecutor.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/repositories/SupabaseContactRepository.d.ts +1 -0
- package/dist/repositories/SupabaseContactRepository.d.ts.map +1 -1
- package/dist/repositories/SupabaseContactRepository.js +1 -0
- package/dist/repositories/SupabaseContactRepository.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/AudienceModule.ts +1 -0
- package/src/builders/RfmSegmentBuilder.ts +279 -0
- package/src/engine/RfmEngine.ts +418 -0
- package/src/engine/V2AudienceEngine.ts +240 -85
- package/src/executors/ClickHouseEventQueryExecutor.ts +853 -0
- package/src/index.ts +2 -0
- package/src/repositories/SupabaseContactRepository.ts +2 -1
- package/src/types/index.ts +6 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
export type RfmEventRow = {
|
|
4
|
+
contact_id?: string | null
|
|
5
|
+
reachy_id?: string | null
|
|
6
|
+
event_name?: string | null
|
|
7
|
+
event_timestamp: string | Date
|
|
8
|
+
event_data?: any
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type RfmContactRow = {
|
|
12
|
+
id: string
|
|
13
|
+
reachy_id?: string | null
|
|
14
|
+
email?: string | null
|
|
15
|
+
phone?: string | null
|
|
16
|
+
is_subscribed?: boolean | null
|
|
17
|
+
communication_preferences?: any
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RfmAggregateRow = {
|
|
21
|
+
contact_id: string
|
|
22
|
+
last_ts: Date
|
|
23
|
+
event_count: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RfmScoredContact = RfmAggregateRow & {
|
|
27
|
+
recency_days: number
|
|
28
|
+
r_score: 1 | 2 | 3 | 4 | 5
|
|
29
|
+
f_score: 1 | 2 | 3 | 4 | 5
|
|
30
|
+
segment_key: string
|
|
31
|
+
email_ok: 0 | 1
|
|
32
|
+
sms_ok: 0 | 1
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type RfmPreviewSegment = {
|
|
36
|
+
segment_key: string
|
|
37
|
+
segment_name: string
|
|
38
|
+
users: number
|
|
39
|
+
percent: number
|
|
40
|
+
avm: number
|
|
41
|
+
email_reachability: number
|
|
42
|
+
sms_reachability: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RfmThresholdRow = {
|
|
46
|
+
kind: 'r' | 'f'
|
|
47
|
+
score: 1 | 2 | 3 | 4 | 5
|
|
48
|
+
min_value: number
|
|
49
|
+
max_value: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type RfmComputeOptions = {
|
|
53
|
+
from: string | Date
|
|
54
|
+
to: string | Date
|
|
55
|
+
rfEventName: string
|
|
56
|
+
filterContactIds?: Set<string> | string[] | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type PercentileCuts = [number, number, number, number]
|
|
60
|
+
|
|
61
|
+
type SegmentRef = {
|
|
62
|
+
segment_key: string
|
|
63
|
+
segment_name: string
|
|
64
|
+
segment_order: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const SEGMENTS_REF: SegmentRef[] = [
|
|
68
|
+
{ segment_key: 'cannot_lose_them', segment_name: 'Cannot Lose Them', segment_order: 1 },
|
|
69
|
+
{ segment_key: 'at_risk', segment_name: 'At Risk', segment_order: 2 },
|
|
70
|
+
{ segment_key: 'hibernating', segment_name: 'Hibernating', segment_order: 3 },
|
|
71
|
+
{ segment_key: 'about_to_sleep', segment_name: 'About to Sleep', segment_order: 4 },
|
|
72
|
+
{ segment_key: 'needing_attention', segment_name: 'Needing Attention', segment_order: 5 },
|
|
73
|
+
{ segment_key: 'promising', segment_name: 'Promising', segment_order: 6 },
|
|
74
|
+
{ segment_key: 'new_users', segment_name: 'New Users', segment_order: 7 },
|
|
75
|
+
{ segment_key: 'potential_loyalists', segment_name: 'Potential Loyalists', segment_order: 8 },
|
|
76
|
+
{ segment_key: 'loyal_users', segment_name: 'Loyal Users', segment_order: 9 },
|
|
77
|
+
{ segment_key: 'champions', segment_name: 'Champions', segment_order: 10 },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
function toDate(input: string | Date): Date {
|
|
81
|
+
if (input instanceof Date) return input
|
|
82
|
+
const d = new Date(String(input || ''))
|
|
83
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Data inválida: ${String(input)}`)
|
|
84
|
+
return d
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function utcDay(d: Date): Date {
|
|
88
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function recencyDays(to: Date, last: Date): number {
|
|
92
|
+
const a = utcDay(to).getTime()
|
|
93
|
+
const b = utcDay(last).getTime()
|
|
94
|
+
return Math.max(0, Math.floor((a - b) / DAY_MS))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSet(ids?: Set<string> | string[] | null): Set<string> | null {
|
|
98
|
+
if (!ids) return null
|
|
99
|
+
if (ids instanceof Set) return new Set(Array.from(ids).map((s) => String(s)))
|
|
100
|
+
return new Set((ids || []).map((s) => String(s)))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function percentileDisc(values: number[], p: number): number {
|
|
104
|
+
const n = values.length
|
|
105
|
+
if (n <= 0) return Number.NaN
|
|
106
|
+
const pp = Math.max(0, Math.min(1, p))
|
|
107
|
+
const k = Math.ceil(pp * n) // 1..n
|
|
108
|
+
const idx = Math.min(n - 1, Math.max(0, k - 1))
|
|
109
|
+
return values[idx]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function percentileDiscCuts(valuesRaw: number[]): PercentileCuts {
|
|
113
|
+
const values = [...valuesRaw].sort((a, b) => a - b)
|
|
114
|
+
return [
|
|
115
|
+
percentileDisc(values, 0.2),
|
|
116
|
+
percentileDisc(values, 0.4),
|
|
117
|
+
percentileDisc(values, 0.6),
|
|
118
|
+
percentileDisc(values, 0.8),
|
|
119
|
+
]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scoreRecency(days: number, cuts: PercentileCuts): 1 | 2 | 3 | 4 | 5 {
|
|
123
|
+
// Menor "days" = mais recente = score maior
|
|
124
|
+
if (days <= cuts[0]) return 5
|
|
125
|
+
if (days <= cuts[1]) return 4
|
|
126
|
+
if (days <= cuts[2]) return 3
|
|
127
|
+
if (days <= cuts[3]) return 2
|
|
128
|
+
return 1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function scoreFrequency(count: number, cuts: PercentileCuts): 1 | 2 | 3 | 4 | 5 {
|
|
132
|
+
// Menor contagem = score menor
|
|
133
|
+
if (count <= cuts[0]) return 1
|
|
134
|
+
if (count <= cuts[1]) return 2
|
|
135
|
+
if (count <= cuts[2]) return 3
|
|
136
|
+
if (count <= cuts[3]) return 4
|
|
137
|
+
return 5
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function computeSegmentKey(r: number, f: number): string {
|
|
141
|
+
if (r >= 4 && f >= 4) return 'champions'
|
|
142
|
+
if (r === 3 && f >= 4) return 'loyal_users'
|
|
143
|
+
if (r >= 4 && f >= 2 && f <= 3) return 'potential_loyalists'
|
|
144
|
+
if (r === 5 && f === 1) return 'new_users'
|
|
145
|
+
if (r === 4 && f === 1) return 'promising'
|
|
146
|
+
if (r === 3 && f === 3) return 'needing_attention'
|
|
147
|
+
if (r === 3 && f <= 2) return 'about_to_sleep'
|
|
148
|
+
if (r <= 2 && f === 5) return 'cannot_lose_them'
|
|
149
|
+
if (r <= 2 && f >= 3 && f <= 4) return 'at_risk'
|
|
150
|
+
return 'hibernating'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function channelsArray(prefs: any): any[] {
|
|
154
|
+
const ch = prefs && typeof prefs === 'object' ? prefs?.channels : undefined
|
|
155
|
+
return Array.isArray(ch) ? ch : []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasChannel(prefs: any, channel: 'email' | 'sms'): boolean {
|
|
159
|
+
const arr = channelsArray(prefs)
|
|
160
|
+
return arr.some((c: any) => String(c?.channel || '').trim().toLowerCase() === channel)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isChannelSubscribed(prefs: any, channel: 'email' | 'sms'): boolean {
|
|
164
|
+
const arr = channelsArray(prefs)
|
|
165
|
+
return arr.some((c: any) => {
|
|
166
|
+
if (String(c?.channel || '').trim().toLowerCase() !== channel) return false
|
|
167
|
+
const sub = c?.subscribed
|
|
168
|
+
return String(sub ?? 'false').trim().toLowerCase() === 'true'
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function reachableBy(prefs: any, channel: 'email' | 'sms'): boolean {
|
|
173
|
+
const rb = prefs && typeof prefs === 'object' ? prefs?.reachable_by : undefined
|
|
174
|
+
const raw = rb && typeof rb === 'object' ? rb?.[channel] : undefined
|
|
175
|
+
return String(raw ?? '').trim().toLowerCase() === 'true'
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function emailOk(contact: RfmContactRow): 0 | 1 {
|
|
179
|
+
const prefs = contact.communication_preferences || {}
|
|
180
|
+
if (contact.is_subscribed === false) return 0
|
|
181
|
+
if (hasChannel(prefs, 'email')) return isChannelSubscribed(prefs, 'email') ? 1 : 0
|
|
182
|
+
if (reachableBy(prefs, 'email')) return 1
|
|
183
|
+
const email = contact.email ? String(contact.email).trim() : ''
|
|
184
|
+
return email ? 1 : 0
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function smsOk(contact: RfmContactRow): 0 | 1 {
|
|
188
|
+
const prefs = contact.communication_preferences || {}
|
|
189
|
+
if (contact.is_subscribed === false) return 0
|
|
190
|
+
if (hasChannel(prefs, 'sms')) return isChannelSubscribed(prefs, 'sms') ? 1 : 0
|
|
191
|
+
if (reachableBy(prefs, 'sms')) return 1
|
|
192
|
+
const phone = contact.phone ? String(contact.phone).trim() : ''
|
|
193
|
+
return phone ? 1 : 0
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export class RfmEngine {
|
|
197
|
+
/**
|
|
198
|
+
* Agrega (last_ts + event_count) por contato, replicando:
|
|
199
|
+
* - filtro de event_name e janela [from..to]
|
|
200
|
+
* - COALESCE(contact_id, contacts.id por reachy_id)
|
|
201
|
+
* - filtro opcional por contact_ids
|
|
202
|
+
*/
|
|
203
|
+
static aggregateFromEvents(
|
|
204
|
+
contacts: RfmContactRow[],
|
|
205
|
+
events: RfmEventRow[],
|
|
206
|
+
opts: RfmComputeOptions
|
|
207
|
+
): RfmAggregateRow[] {
|
|
208
|
+
const from = toDate(opts.from).getTime()
|
|
209
|
+
const to = toDate(opts.to).getTime()
|
|
210
|
+
const evName = String(opts.rfEventName || '').trim()
|
|
211
|
+
const filterSet = normalizeSet(opts.filterContactIds)
|
|
212
|
+
|
|
213
|
+
const contactById = new Map<string, RfmContactRow>()
|
|
214
|
+
const reachyToContactId = new Map<string, string>()
|
|
215
|
+
for (const c of contacts || []) {
|
|
216
|
+
const cid = c?.id ? String(c.id).trim() : ''
|
|
217
|
+
if (cid) contactById.set(cid, c)
|
|
218
|
+
const rid = c?.reachy_id ? String(c.reachy_id).trim() : ''
|
|
219
|
+
if (rid && cid && !reachyToContactId.has(rid)) reachyToContactId.set(rid, cid)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const acc = new Map<string, { last: number; count: number }>()
|
|
223
|
+
|
|
224
|
+
for (const e of events || []) {
|
|
225
|
+
const name = e?.event_name != null ? String(e.event_name) : ''
|
|
226
|
+
if (evName && name !== evName) continue
|
|
227
|
+
|
|
228
|
+
const ts = toDate(e.event_timestamp).getTime()
|
|
229
|
+
if (ts < from || ts > to) continue
|
|
230
|
+
|
|
231
|
+
const rawCid = e?.contact_id ? String(e.contact_id).trim() : ''
|
|
232
|
+
const rawRid = e?.reachy_id ? String(e.reachy_id).trim() : ''
|
|
233
|
+
if (!rawCid && !rawRid) continue
|
|
234
|
+
|
|
235
|
+
const mappedCid = (() => {
|
|
236
|
+
if (rawCid && contactById.has(rawCid)) return rawCid
|
|
237
|
+
if (rawRid) {
|
|
238
|
+
const mapped = reachyToContactId.get(rawRid)
|
|
239
|
+
if (mapped && contactById.has(mapped)) return mapped
|
|
240
|
+
}
|
|
241
|
+
return ''
|
|
242
|
+
})()
|
|
243
|
+
|
|
244
|
+
if (!mappedCid) continue
|
|
245
|
+
if (filterSet && !filterSet.has(mappedCid)) continue
|
|
246
|
+
|
|
247
|
+
const cur = acc.get(mappedCid)
|
|
248
|
+
if (!cur) {
|
|
249
|
+
acc.set(mappedCid, { last: ts, count: 1 })
|
|
250
|
+
} else {
|
|
251
|
+
cur.count += 1
|
|
252
|
+
if (ts > cur.last) cur.last = ts
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const out: RfmAggregateRow[] = []
|
|
257
|
+
acc.forEach((v, contactId) => {
|
|
258
|
+
out.push({
|
|
259
|
+
contact_id: contactId,
|
|
260
|
+
last_ts: new Date(v.last),
|
|
261
|
+
event_count: Math.max(0, Math.trunc(v.count)),
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
return out
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
static scoreAggregates(
|
|
268
|
+
contacts: RfmContactRow[],
|
|
269
|
+
aggregates: RfmAggregateRow[],
|
|
270
|
+
opts: Pick<RfmComputeOptions, 'to'>
|
|
271
|
+
): { scored: RfmScoredContact[]; r_days_cuts: PercentileCuts; f_count_cuts: PercentileCuts } {
|
|
272
|
+
const to = toDate(opts.to)
|
|
273
|
+
|
|
274
|
+
const contactById = new Map<string, RfmContactRow>()
|
|
275
|
+
for (const c of contacts || []) {
|
|
276
|
+
const cid = c?.id ? String(c.id).trim() : ''
|
|
277
|
+
if (cid) contactById.set(cid, c)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rows: Array<{ agg: RfmAggregateRow; days: number }> = []
|
|
281
|
+
const recencyValues: number[] = []
|
|
282
|
+
const freqValues: number[] = []
|
|
283
|
+
|
|
284
|
+
for (const a of aggregates || []) {
|
|
285
|
+
const cid = a?.contact_id ? String(a.contact_id).trim() : ''
|
|
286
|
+
if (!cid) continue
|
|
287
|
+
if (!contactById.has(cid)) continue // paridade com JOIN contacts
|
|
288
|
+
const days = recencyDays(to, a.last_ts)
|
|
289
|
+
rows.push({ agg: a, days })
|
|
290
|
+
recencyValues.push(days)
|
|
291
|
+
freqValues.push(Math.max(0, Math.trunc(a.event_count)))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const rCuts = percentileDiscCuts(recencyValues)
|
|
295
|
+
const fCuts = percentileDiscCuts(freqValues)
|
|
296
|
+
|
|
297
|
+
const scored: RfmScoredContact[] = []
|
|
298
|
+
for (const r of rows) {
|
|
299
|
+
const c = contactById.get(r.agg.contact_id)!
|
|
300
|
+
const rScore = scoreRecency(r.days, rCuts)
|
|
301
|
+
const fScore = scoreFrequency(Math.max(0, Math.trunc(r.agg.event_count)), fCuts)
|
|
302
|
+
scored.push({
|
|
303
|
+
contact_id: r.agg.contact_id,
|
|
304
|
+
last_ts: r.agg.last_ts,
|
|
305
|
+
event_count: Math.max(0, Math.trunc(r.agg.event_count)),
|
|
306
|
+
recency_days: r.days,
|
|
307
|
+
r_score: rScore,
|
|
308
|
+
f_score: fScore,
|
|
309
|
+
segment_key: computeSegmentKey(rScore, fScore),
|
|
310
|
+
email_ok: emailOk(c),
|
|
311
|
+
sms_ok: smsOk(c),
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { scored, r_days_cuts: rCuts, f_count_cuts: fCuts }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static thresholdsFromScored(scored: RfmScoredContact[]): RfmThresholdRow[] {
|
|
319
|
+
const rMin = new Map<number, number>()
|
|
320
|
+
const rMax = new Map<number, number>()
|
|
321
|
+
const fMin = new Map<number, number>()
|
|
322
|
+
const fMax = new Map<number, number>()
|
|
323
|
+
|
|
324
|
+
for (const s of scored || []) {
|
|
325
|
+
const rScore = s.r_score
|
|
326
|
+
const fScore = s.f_score
|
|
327
|
+
const rVal = Math.max(0, Math.trunc(s.recency_days))
|
|
328
|
+
const fVal = Math.max(0, Math.trunc(s.event_count))
|
|
329
|
+
|
|
330
|
+
const rMinPrev = rMin.get(rScore)
|
|
331
|
+
if (rMinPrev == null || rVal < rMinPrev) rMin.set(rScore, rVal)
|
|
332
|
+
const rMaxPrev = rMax.get(rScore)
|
|
333
|
+
if (rMaxPrev == null || rVal > rMaxPrev) rMax.set(rScore, rVal)
|
|
334
|
+
|
|
335
|
+
const fMinPrev = fMin.get(fScore)
|
|
336
|
+
if (fMinPrev == null || fVal < fMinPrev) fMin.set(fScore, fVal)
|
|
337
|
+
const fMaxPrev = fMax.get(fScore)
|
|
338
|
+
if (fMaxPrev == null || fVal > fMaxPrev) fMax.set(fScore, fVal)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const out: RfmThresholdRow[] = []
|
|
342
|
+
const scores: Array<1 | 2 | 3 | 4 | 5> = [1, 2, 3, 4, 5]
|
|
343
|
+
|
|
344
|
+
for (const score of scores) {
|
|
345
|
+
if (rMin.has(score) && rMax.has(score)) {
|
|
346
|
+
out.push({
|
|
347
|
+
kind: 'r',
|
|
348
|
+
score,
|
|
349
|
+
min_value: rMin.get(score)!,
|
|
350
|
+
max_value: rMax.get(score)!,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const score of scores) {
|
|
356
|
+
if (fMin.has(score) && fMax.has(score)) {
|
|
357
|
+
out.push({
|
|
358
|
+
kind: 'f',
|
|
359
|
+
score,
|
|
360
|
+
min_value: fMin.get(score)!,
|
|
361
|
+
max_value: fMax.get(score)!,
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// mesmo ORDER BY kind, score do SQL (texto): 'f' vem antes de 'r'
|
|
367
|
+
out.sort((a, b) => (a.kind === b.kind ? a.score - b.score : a.kind.localeCompare(b.kind)))
|
|
368
|
+
return out
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
static previewFromScored(scored: RfmScoredContact[]): RfmPreviewSegment[] {
|
|
372
|
+
const totalUsers = scored.length
|
|
373
|
+
const agg = new Map<
|
|
374
|
+
string,
|
|
375
|
+
{ users: number; email_reachability: number; sms_reachability: number; segment_spend: number }
|
|
376
|
+
>()
|
|
377
|
+
|
|
378
|
+
for (const s of scored || []) {
|
|
379
|
+
const key = String(s.segment_key || '').trim() || 'hibernating'
|
|
380
|
+
const cur = agg.get(key) || { users: 0, email_reachability: 0, sms_reachability: 0, segment_spend: 0 }
|
|
381
|
+
cur.users += 1
|
|
382
|
+
cur.email_reachability += s.email_ok
|
|
383
|
+
cur.sms_reachability += s.sms_ok
|
|
384
|
+
// spend/avm não é implementado nas funções atuais; manter 0
|
|
385
|
+
agg.set(key, cur)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const out: RfmPreviewSegment[] = []
|
|
389
|
+
for (const seg of SEGMENTS_REF) {
|
|
390
|
+
const a = agg.get(seg.segment_key) || { users: 0, email_reachability: 0, sms_reachability: 0, segment_spend: 0 }
|
|
391
|
+
const percent = totalUsers > 0 ? Math.round((a.users * 10000) / totalUsers) / 100 : 0
|
|
392
|
+
const avm = a.users > 0 ? 0 : 0
|
|
393
|
+
out.push({
|
|
394
|
+
segment_key: seg.segment_key,
|
|
395
|
+
segment_name: seg.segment_name,
|
|
396
|
+
users: a.users,
|
|
397
|
+
percent,
|
|
398
|
+
avm,
|
|
399
|
+
email_reachability: a.email_reachability,
|
|
400
|
+
sms_reachability: a.sms_reachability,
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
return out
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
static computeFromEvents(
|
|
407
|
+
contacts: RfmContactRow[],
|
|
408
|
+
events: RfmEventRow[],
|
|
409
|
+
opts: RfmComputeOptions
|
|
410
|
+
): { segments: RfmPreviewSegment[]; thresholds: RfmThresholdRow[]; scored: RfmScoredContact[] } {
|
|
411
|
+
const aggregates = this.aggregateFromEvents(contacts, events, opts)
|
|
412
|
+
const { scored } = this.scoreAggregates(contacts, aggregates, { to: opts.to })
|
|
413
|
+
const thresholds = this.thresholdsFromScored(scored)
|
|
414
|
+
const segments = this.previewFromScored(scored)
|
|
415
|
+
return { segments, thresholds, scored }
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|