新增订阅额度风险预警计算
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 风险预警计算:算出未来一段时间内,有哪些提醒会因额度不足发不出去
|
||||||
|
*
|
||||||
|
* 首页拿这个结果给用户看「谁的提醒有风险」,而不是「还剩几次额度」——
|
||||||
|
* 用户理解不了额度这种抽象概念,但看得懂人名。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { getNextOccurrence, daysBetween } = require('./occurrence')
|
||||||
|
|
||||||
|
// 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。
|
||||||
|
const LOOKAHEAD_DAYS = 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一条纪念日展开成未来窗口内的提醒事件
|
||||||
|
* 每条纪念日最多产生两个事件:提前 N 天一个、当天一个
|
||||||
|
*/
|
||||||
|
function expandEvents(anniv, today = new Date()) {
|
||||||
|
const next = getNextOccurrence(anniv, today)
|
||||||
|
const onDayIn = daysBetween(next, today)
|
||||||
|
const remindDays = anniv.remindDays || 0
|
||||||
|
|
||||||
|
const events = [{
|
||||||
|
anniversaryId: anniv.id,
|
||||||
|
personName: anniv.personName,
|
||||||
|
kind: 'onDay',
|
||||||
|
fireInDays: onDayIn,
|
||||||
|
importance: anniv.importance
|
||||||
|
}]
|
||||||
|
|
||||||
|
if (remindDays > 0) {
|
||||||
|
events.push({
|
||||||
|
anniversaryId: anniv.id,
|
||||||
|
personName: anniv.personName,
|
||||||
|
kind: 'ahead',
|
||||||
|
fireInDays: onDayIn - remindDays,
|
||||||
|
importance: anniv.importance
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已经过去的(如提前提醒的时点早已过)和超出窗口的都不算
|
||||||
|
return events.filter(e => e.fireInDays >= 0 && e.fireInDays <= LOOKAHEAD_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 算出有风险的提醒
|
||||||
|
* 排序用「时间先后」而非重要程度——跨天的额度就是先到先消耗。
|
||||||
|
* 重要程度只在同一天内比较(那部分逻辑在 reminder.js)。
|
||||||
|
*/
|
||||||
|
function computeAtRisk(anniversaries, balance, today = new Date()) {
|
||||||
|
const events = (anniversaries || [])
|
||||||
|
.filter(a => a && a.remindEnabled)
|
||||||
|
.flatMap(a => expandEvents(a, today))
|
||||||
|
.sort((x, y) => x.fireInDays - y.fireInDays)
|
||||||
|
|
||||||
|
const safeCount = Math.max(0, balance)
|
||||||
|
const atRisk = events.slice(safeCount)
|
||||||
|
|
||||||
|
const names = []
|
||||||
|
for (const e of atRisk) {
|
||||||
|
if (e.personName && !names.includes(e.personName)) names.push(e.personName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { atRiskCount: atRisk.length, atRiskNames: names }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { LOOKAHEAD_DAYS, expandEvents, computeAtRisk }
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert')
|
||||||
|
const { expandEvents, computeAtRisk, LOOKAHEAD_DAYS } = require('../src/atRisk')
|
||||||
|
|
||||||
|
const TODAY = new Date(2026, 7, 15) // 2026-08-15
|
||||||
|
|
||||||
|
// 距今 10 天的公历纪念日,提前 3 天提醒
|
||||||
|
const near = {
|
||||||
|
id: 'a1', personName: '张三', remindEnabled: 1, importance: 'high',
|
||||||
|
isLunar: false, solarMonth: 8, solarDay: 25, remindDays: 3
|
||||||
|
}
|
||||||
|
// 距今 40 天
|
||||||
|
const mid = {
|
||||||
|
id: 'a2', personName: '李四', remindEnabled: 1, importance: 'low',
|
||||||
|
isLunar: false, solarMonth: 9, solarDay: 24, remindDays: 7
|
||||||
|
}
|
||||||
|
|
||||||
|
test('LOOKAHEAD_DAYS 为 60', () => {
|
||||||
|
assert.strictEqual(LOOKAHEAD_DAYS, 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('一条纪念日展开出「当天」和「提前」两个事件', () => {
|
||||||
|
const events = expandEvents(near, TODAY)
|
||||||
|
assert.strictEqual(events.length, 2)
|
||||||
|
const onDay = events.find(e => e.kind === 'onDay')
|
||||||
|
const ahead = events.find(e => e.kind === 'ahead')
|
||||||
|
assert.strictEqual(onDay.fireInDays, 10)
|
||||||
|
assert.strictEqual(ahead.fireInDays, 7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remindDays 为 0 时只有当天事件', () => {
|
||||||
|
const events = expandEvents({ ...near, remindDays: 0 }, TODAY)
|
||||||
|
assert.strictEqual(events.length, 1)
|
||||||
|
assert.strictEqual(events[0].kind, 'onDay')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('超出 60 天窗口的事件被过滤掉', () => {
|
||||||
|
const far = { ...near, solarMonth: 12, solarDay: 25, remindDays: 3 }
|
||||||
|
assert.strictEqual(expandEvents(far, TODAY).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('提前日已过、当天未到时,只剩当天事件', () => {
|
||||||
|
// 距今 2 天,但提前 7 天的时点早已过去
|
||||||
|
const soon = { ...near, solarMonth: 8, solarDay: 17, remindDays: 7 }
|
||||||
|
const events = expandEvents(soon, TODAY)
|
||||||
|
assert.strictEqual(events.length, 1)
|
||||||
|
assert.strictEqual(events[0].kind, 'onDay')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('余额足够时没有风险', () => {
|
||||||
|
const r = computeAtRisk([near, mid], 10, TODAY)
|
||||||
|
assert.strictEqual(r.atRiskCount, 0)
|
||||||
|
assert.deepStrictEqual(r.atRiskNames, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('余额为 0 时全部有风险', () => {
|
||||||
|
const r = computeAtRisk([near, mid], 0, TODAY)
|
||||||
|
assert.strictEqual(r.atRiskCount, 4)
|
||||||
|
assert.deepStrictEqual(r.atRiskNames, ['张三', '李四'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('按时间先后消耗额度,后面的才有风险', () => {
|
||||||
|
// 张三的两个事件在第 7、10 天,李四的在第 33、40 天
|
||||||
|
const r = computeAtRisk([near, mid], 2, TODAY)
|
||||||
|
assert.strictEqual(r.atRiskCount, 2)
|
||||||
|
assert.deepStrictEqual(r.atRiskNames, ['李四'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('人名去重,同一人占两条只出现一次', () => {
|
||||||
|
const r = computeAtRisk([near], 0, TODAY)
|
||||||
|
assert.strictEqual(r.atRiskCount, 2)
|
||||||
|
assert.deepStrictEqual(r.atRiskNames, ['张三'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('未开启提醒的纪念日不参与计算', () => {
|
||||||
|
const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY)
|
||||||
|
assert.strictEqual(r.atRiskCount, 0)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user