9dc22cd1e6
TDD(批次A Task4):classify/day_add/days_between 纯函数,供 Task6 宽表 生命周期 4 列计算。口径已确认:新客=首次互动≤7 天(含当天共 7 天)、 cooling [7,30)、沉睡≥30 天且历史≥5 条(对齐 churn real min_msgs=5)、 回流=当日互动(last_day==as_of)且前次间隔>30 天(严格大于,恰好 30 天不算);未激活或历史不足的长期静默返回 None。analytics 现有页暂不 切换(v2 §5 口径差异另标),不动 636 既有测试。 模块为纯叶子节点,禁止 import app.storage(防 storage→analytics 成环)。 7 例测试覆盖:new 窗口边界(as_of-6 含/不含)、回流 35/9/31 天与恰好 30 天、last_day==as_of 门槛、6/7/29/30 天落位、历史门槛 4/5、 day_add 跨月。全量 643 绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
72 lines
2.2 KiB
Python
72 lines
2.2 KiB
Python
"""客户生命周期判定(管理域,批次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
|