diff --git a/yunke-server/app/analytics/lifecycle.py b/yunke-server/app/analytics/lifecycle.py new file mode 100644 index 0000000..7813b33 --- /dev/null +++ b/yunke-server/app/analytics/lifecycle.py @@ -0,0 +1,71 @@ +"""客户生命周期判定(管理域,批次A)——距今天数口径(v2 §1.1 #3 决议)。 + +阈值取已确认默认(v1 §21,用户拍板):新客 7 天、沉睡 30 天;历史互动 +门槛 MIN_HISTORY_MSGS=5 对齐 analytics churn real 的 min_msgs=5。 +analytics 现有页(churn/客户资产)**暂不切换**到此模块——两套数字的 +差异在 metric-definitions 标注(v2 §5);后续统一时再接,避免动既有 +测试。 + +本模块为纯叶子节点,禁止 import app.storage(防止 storage→analytics +消费时成环)。 +""" + +from __future__ import annotations + +import datetime as dt + +NEW_DAYS = 7 +DORMANT_DAYS = 30 +MIN_HISTORY_MSGS = 5 + +STATES = ("new", "active", "cooling", "dormant", "reactivated") + + +def _to_date(day: int) -> dt.date: + return dt.date(day // 10000, (day // 100) % 100, day % 100) + + +def day_add(day: int, delta_days: int) -> int: + """yyyymmdd 键加减天数。""" + d = _to_date(day) + dt.timedelta(days=delta_days) + return d.year * 10000 + d.month * 100 + d.day + + +def days_between(day_a: int, day_b: int) -> int: + """b − a 的天数(输入 yyyymmdd 键)。""" + return (_to_date(day_b) - _to_date(day_a)).days + + +def classify( + first_day: int | None, + last_day: int | None, + prev_day: int | None, + as_of: int, + hist_msgs: int, +) -> str | None: + """as_of 日的客户状态;输入均为互动日键(yyyymmdd,上海自然日)。 + + - first_day: 该客户全历史首次互动日;last_day: ≤as_of 的最近互动日; + prev_day: last_day 之前的一次互动日(无则 None);hist_msgs: 历史总消息数。 + - 返回 None = 未激活(first/last 缺)或历史不足的长期静默(不计入任何列)。 + + 判定优先级:new → reactivated → active → cooling → dormant。 + """ + if first_day is None or last_day is None: + return None + if first_day >= day_add(as_of, -(NEW_DAYS - 1)): + return "new" + if ( + last_day == as_of + and prev_day is not None + and days_between(prev_day, last_day) > DORMANT_DAYS + ): + return "reactivated" + gap = days_between(last_day, as_of) + if gap < NEW_DAYS: + return "active" + if gap < DORMANT_DAYS: + return "cooling" + if hist_msgs >= MIN_HISTORY_MSGS: + return "dormant" + return None diff --git a/yunke-server/tests/test_lifecycle.py b/yunke-server/tests/test_lifecycle.py new file mode 100644 index 0000000..490b4d9 --- /dev/null +++ b/yunke-server/tests/test_lifecycle.py @@ -0,0 +1,72 @@ +"""客户生命周期判定纯函数测试(批次A Task4)。 + +口径(v2 §1.1 #3 + v1 §21 默认,已用户确认):新客 7 天、沉睡 30 天、 +历史≥5 条才算沉睡(对齐 churn real min_msgs=5)。analytics 现有页 +(churn/客户资产)暂不切换(口径差异 v2 §5 标注),不动 636 既有测试。 +""" + +from app.analytics.lifecycle import DORMANT_DAYS, MIN_HISTORY_MSGS, NEW_DAYS, classify, day_add + + +def test_constants_locked_to_confirmed_defaults(): + assert NEW_DAYS == 7 and DORMANT_DAYS == 30 and MIN_HISTORY_MSGS == 5 + + +def test_new_customer(): + # 首次沟通在 7 天窗口内 + assert ( + classify(first_day=20260901, last_day=20260903, prev_day=None, as_of=20260905, hist_msgs=2) + == "new" + ) + # 窗口边界:as_of-6=20260830 含当天共 7 天 → new;再早一天 → 非 new + assert classify(20260830, 20260830, None, 20260905, 100) == "new" + assert classify(20260829, 20260829, None, 20260905, 100) == "cooling" + + +def test_reactivated_requires_gap_over_30d(): + # 当日回流且前次间隔>30 天 + assert ( + classify( + first_day=20260101, last_day=20260905, prev_day=20260801, as_of=20260905, hist_msgs=50 + ) + == "reactivated" + ) + # 间隔仅 9 天 → 只是 active + assert ( + classify( + first_day=20260101, last_day=20260905, prev_day=20260827, as_of=20260905, hist_msgs=50 + ) + == "active" + ) + # 间隔恰好 30 天 → 严格 >30 不算回流,落回 active + assert ( + classify( + first_day=20260101, last_day=20260905, prev_day=20260806, as_of=20260905, hist_msgs=50 + ) + == "active" + ) + # 回流必须当日互动:前次间隔 31 天(>30)但 last_day=as_of-1 → 不算回流,按 gap=1 落 active + assert classify(20260101, 20260904, 20260804, 20260905, 50) == "active" + + +def test_active_cooling_dormant_boundaries(): + f, m = 20260101, 100 + assert classify(f, 20260830, None, 20260905, m) == "active" # 6 天 + assert classify(f, 20260829, None, 20260905, m) == "cooling" # 7 天 + assert classify(f, 20260807, None, 20260905, m) == "cooling" # 29 天 + assert classify(f, 20260806, None, 20260905, m) == "dormant" # 30 天 + + +def test_dormant_requires_history(): + # ≥30 天无互动但历史<5 条:不计入任何状态(None) + assert classify(20260101, 20260806, None, 20260905, 4) is None + assert classify(20260101, 20260806, None, 20260905, 5) == "dormant" + + +def test_no_interaction_returns_none(): + assert classify(None, None, None, 20260905, 0) is None + + +def test_day_add(): + assert day_add(20260831, 1) == 20260901 + assert day_add(20260901, -7) == 20260825