rules.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. # -*- coding: utf-8 -*-
  2. """
  3. 考核指标默认值 + 门店健康诊断规则(v2.1)
  4. 设计依据:项目记录/规则全景图_可编辑.html(2026-11-01 用户确认版)
  5. - 按漏斗分 4 层:流量 → 转化 → 履约 → 口碑
  6. - 5 档:R 违规红线 / B 考核未达标 / T 运营提升 / N 样本不足
  7. - 核销链内部去重:零成交 > 成交未核销 > 核销率低(只报最上游一条)
  8. - 核销金额独立判定(用户 2026-11-01 定)
  9. - 1 棵诊断树选「主因」,问题全列,每个问题都给对应动作
  10. - 全项目唯一的规则实现,前端只负责渲染
  11. """
  12. import re
  13. # 官方默认基准线(可在工作台【指标配置】修改,修改后存库)
  14. DEFAULT_THRESHOLDS = {
  15. # —— 飞书《门店考核指标》官方基准 ——
  16. "score_base": 4.3,
  17. "score_target": 4.5,
  18. "hx_rate_base": 0.4,
  19. "hx_rate_target": 0.6,
  20. "hx_amt_base": 10000,
  21. "tuDan": [0.0127, 0.0318, 0.0635], # 商责退单率 预警/一般严重/非常严重
  22. "touSu": [0.0011, 0.0028, 0.0056], # 投诉率
  23. "chaPing": [0.0066, 0.0166, 0.0331], # 差评率
  24. "reply_base": 50, # 评价回复率(%) 百分数
  25. # —— v2 新增:判定口径与门槛 ——
  26. "visit_low": 100, # 访问低于此次数 → 访问过低
  27. "cvr_low": 10, # 成交转化率(%) 低于此 → 转化低(2026-11-01 品牌口径)
  28. "pend_high": 10, # 未核销券积压达到此次数 → 积压提醒
  29. "hx_min_deal": 5, # 核销率判定要求的最小意向成交券数
  30. "amt_min_coupons": 5, # 「客单价偏低」判定要求的最少核销券数
  31. "cp_min_new": 10, # 差评率:新增口径最小样本
  32. "cp_min_total": 20, # 差评率:累计口径最小样本
  33. "reply_min_new": 1, # 回复率判定要求的最小新增评价数
  34. "video_low": 10, # 新发布视频低于此数 → 视频量少
  35. "lit_low": 10, # 门店点亮低于此数 → 点亮次数偏低
  36. "new_review_low": 10, # 新增评价低于此数 → 新增评价少
  37. }
  38. LEVEL_WEIGHT = {"R3": 3, "R2": 2, "R1": 1, "B": 0.6, "T": 0.3, "N": 0}
  39. LEVEL_NAME = {"R1": "预警线", "R2": "一般严重线", "R3": "非常严重线"}
  40. LAYER_NAME = {"traffic": "流量", "convert": "转化", "fulfill": "履约", "repute": "口碑"}
  41. # 建议动作库
  42. ACT_TD = "调整门店接待流程避免商责退单进一步加重,主动补20单核销好评,降低商责退单占比"
  43. ACT_TS = "调整门店接待流程避免客诉进一步加重"
  44. ACT_CP = "逐条处理近30天差评,主攻优质深度好评(避免无效评价)"
  45. ACT_SCORE = "优先引导带图深度好评,需先将门店评分提升至{base}以上"
  46. ACT_SCORE0 = "优先引导带图深度好评,达到开分门槛后评分才会生成"
  47. ACT_HX = "每天使用客资中心邀约未核销客户,设置到店核销激励"
  48. ACT_ZERO = "触达全部已成交客户确认到店意愿,核查门店核销流程是否卡单"
  49. ACT_AMT = "每月至少完成1.2w大单上翻,保持金牌门店,获取平台流量激励"
  50. ACT_REPLY = "安排时间定时回复新增评价,差评24小时内及时处理"
  51. ACT_VISIT = "1. 检查门店是否有违规限流\n2.点亮门店+更新AIGC素材,增加门店投稿,提升门店曝光"
  52. ACT_VISIT0 = "核查门店是否营业/被限流,确认团购在售,完成门店点亮"
  53. ACT_VIDEO = "每周用后台AIGC工具产出1条图文或视频"
  54. ACT_LIT = "店内需放置优惠码物料,积极引导到店客户点亮门店"
  55. ACT_CVR = "提高门店基建数据,增加进店转化"
  56. ACT_DEAL0 = "检查团购是否在售、门店是否有违规限流"
  57. ACT_PEND = "每天使用客资中心及时邀约新增客资"
  58. ACT_RVOL = "每月保持不低于15条新增优质评价"
  59. ACT_RANK0 = "连续7天交易上翻 + 主攻好评冲本地榜单;上榜可改善平台限流"
  60. ACT_RANK_IN = "在已入围榜单上持续做好评与交易增量,把名次冲上去"
  61. # ---------------------------------------------------------------------------
  62. # 文案模板(全部可在前端「文案配置」面板自定义,改后存库、全店重算)
  63. # 可用占位符见各条注释;未知占位符原样保留,不会报错
  64. # ---------------------------------------------------------------------------
  65. DEFAULT_TEXTS = {
  66. # —— 反馈文案结构 ——
  67. "fb_sec_overview": "✅经营数据概览:",
  68. "fb_line_overview": "近30天:客资{客资} 核销{核销} 退单{退单}",
  69. "fb_sec_risk": "✅风险或限流因素:",
  70. "fb_item_risk": "{i}、{问题}",
  71. "fb_line_pending": "✅后台待核销订单{待核销}单(估算未核销)",
  72. "fb_sec_loss": "✅顾客流失分析(近30天):",
  73. "fb_item_visit": "{i}、门店页有效人数 {访问} 人({访问环比})",
  74. "fb_item_deal": "{i}、成交 {成交} 人(占比 {成交占比} )",
  75. "fb_item_lost": "{i}、流失 {流失} 人(占比 {流失占比} )",
  76. "fb_sec_insight": "✅分析洞察:",
  77. "fb_item_insight": "{i}、{洞察}",
  78. "fb_insight_metric": "指标层面:核销率仅 {核销率},距离 {核销目标} 基准线差距明显,是门店第一大限流指标,平台自然流量权重持续承压,推荐量会持续收紧。",
  79. "fb_insight_score": "口碑分:团购评分 {评分} 低于平台准入基准 {评分基准},评分权重直接影响自然流量推荐与转化。",
  80. "fb_insight_flow": "流量转化:门店页访客 {访问} 人,成交转化率仅 {成交占比},{流失占比} 访客流失,用户浏览后更容易被隔壁同类门店抢走。提升门店基建数据,增加进店转化。",
  81. "fb_insight_order": "订单风险:后台积压 {待核销} 单待核销订单,存量体量较大,如不主动跟进邀约,后期极易集中产生退单、负面评价,进一步拉低评分,形成恶性循环。",
  82. "fb_insight_reply": "口碑管理:评价回复率仅 {回复率},低于 {回复基准} 考核要求,差评无人回应会进一步压低门店权重。",
  83. "fb_insight_priority": "整改优先级:优先电话 / 私信触达待核销客户,加速到店核销;服务环节引导优质评价拉升团购分。",
  84. "fb_sec_action": "📌 门店调整动作",
  85. "fb_no_issue": "本期无明显异常",
  86. # —— 一句话结论(诊断树选出的主因)——
  87. "fb_sec_headline": "✅本期结论:",
  88. "fb_item_headline": "{结论}",
  89. "hd_R": "有违规在限流:{问题} —— 平台已在处置或限流,先处理违规项再谈增长",
  90. "hd_traffic": "卡在「流量」:{问题} —— 没有足够流量进店,后面几步都无从谈起",
  91. "hd_convert": "卡在「转化」:{问题} —— 有流量但没下单,转化环节断掉了",
  92. "hd_fulfill": "卡在「履约」:{问题} —— 成交了但没核销,钱卡在未履约这一环",
  93. "hd_repute": "卡在「口碑」:{问题} —— 口碑在拖累自然流量与转化",
  94. "hd_AMT": "核销金额没达标:{问题} —— 要靠大单上翻把金额做起来,才能保住金牌门店和流量激励",
  95. "hd_SCORE0": "卡在「口碑」:{问题} —— 还没开分,先把有效评价量做起来",
  96. "hd_OK": "本期各项指标均未触发问题",
  97. # —— 整改动作条目(来自诊断树,最多 3 条)——
  98. "fb_action_item": "✅ {动作}",
  99. # —— 严重档位名称 ——
  100. "lv_R1": "预警线",
  101. "lv_R2": "一般严重线",
  102. "lv_R3": "非常严重线",
  103. # —— 问题文案:{v}=实际值 {base}=基准值 {口径}=新增/累计 {档}=档位名 ——
  104. "t_TD": "商责退单率{v}%超{档}",
  105. "t_TS": "投诉率{v}%超{档}",
  106. "t_CP": "差评率({口径}口径){v}%超{档}",
  107. "t_SCORE": "评分{v}(基准线{base})-低于平台准入",
  108. "t_SCORE0": "未开分({原因}),暂无有效评分",
  109. "t_HX": "核销率{v}%(基准线{base}%)",
  110. "t_ZERO": "成交{n}张券一张未核销",
  111. "t_AMT": "当月核销{金额}元,未做大单上翻",
  112. "t_REPLY": "新增{新增}条评价,回复率{v}%偏低",
  113. "t_VISIT": "门店曝光访问偏低",
  114. "t_VIDEO": "门店发布视频数量少",
  115. "t_LIT": "门店点亮次数偏低",
  116. "t_CVR": "成交转化率仅{v}%",
  117. "t_DEAL0": "有{v}次访问却零成交",
  118. "t_PEND": "积压{p}张未核销券",
  119. "t_RANK0": "未上榜(好评榜/人气榜均未进入)",
  120. "t_RANK_IN": "仅入围未进榜:{榜}",
  121. "t_RVOL": "近30天新增评价少",
  122. # —— 整改建议(评分建议可用 {base} {target})——
  123. "a_TD": ACT_TD,
  124. "a_TS": ACT_TS,
  125. "a_CP": ACT_CP,
  126. "a_SCORE": ACT_SCORE,
  127. "a_SCORE0": ACT_SCORE0,
  128. "a_HX": ACT_HX,
  129. "a_ZERO": ACT_ZERO,
  130. "a_AMT": ACT_AMT,
  131. "a_REPLY": ACT_REPLY,
  132. "a_VISIT": ACT_VISIT,
  133. "a_VIDEO": ACT_VIDEO,
  134. "a_LIT": ACT_LIT,
  135. "a_CVR": ACT_CVR,
  136. "a_DEAL0": ACT_DEAL0,
  137. "a_PEND": ACT_PEND,
  138. "a_RVOL": ACT_RVOL,
  139. "a_RANK0": ACT_RANK0,
  140. "a_RANK_IN": ACT_RANK_IN,
  141. }
  142. # v1 的默认文案快照:用于迁移。
  143. # 库里存的是整份文案(用户点过「应用」就全量落库),改默认值后旧存档会盖住新默认。
  144. # 规则:存档值 == v1 默认值 → 说明用户没改过,直接采用新默认;否则视为用户自定义,保留。
  145. V1_TEXTS = {
  146. "fb_sec_overview": "✅经营数据概览:",
  147. "fb_line_overview": "近30天:客资{客资} 核销{核销} 退单{退单}",
  148. "fb_sec_risk": "✅风险或限流因素:",
  149. "fb_item_risk": "{i}、{问题}",
  150. "fb_line_pending": "✅后台待核销订单{待核销}单(真实未核销)",
  151. "fb_sec_loss": "✅顾客流失分析(近30天):",
  152. "fb_item_visit": "{i}、门店页有效人数 {访问} 人({访问环比})",
  153. "fb_item_deal": "{i}、成交 {成交} 人(占比 {成交占比} )",
  154. "fb_item_lost": "{i}、流失 {流失} 人(占比 {流失占比} )",
  155. "fb_sec_insight": "✅分析洞察:",
  156. "fb_item_insight": "{i}、{洞察}",
  157. "fb_insight_metric": "指标层面:核销率仅 {核销率},距离 {核销目标} 基准线差距明显,是门店第一大限流指标,平台自然流量权重持续承压,推荐量会持续收紧。",
  158. "fb_insight_score": "口碑分:团购评分 {评分} 低于平台准入基准 {评分基准},评分权重直接影响自然流量推荐与转化。",
  159. "fb_insight_flow": "流量转化:门店页访客 {访问} 人,成交转化率仅 {成交占比},{流失占比} 访客流失,用户浏览后更容易被隔壁同类门店抢走,门店团购套餐、主页吸引力不足。",
  160. "fb_insight_order": "订单风险:后台积压 {待核销} 单待核销订单,存量体量较大,如不主动跟进邀约,后期极易集中产生退单、负面评价,进一步拉低评分,形成恶性循环。",
  161. "fb_insight_reply": "口碑管理:评价回复率仅 {回复率},低于 {回复基准} 考核要求,差评无人回应会进一步压低门店权重。",
  162. "fb_insight_priority": "整改优先级:优先电话 / 私信触达待核销客户,加速到店核销;服务环节引导优质评价拉升团购分。",
  163. "fb_sec_action": "📌 门店调整动作",
  164. "fb_actions": "✅ 做评价:主攻优质深度好评\n避免短期,避免无效评价,长期做,稳定做,多渠道来客的都友好引导写评价,避免强硬逼迫写评价!\n✅ 传素材:后台 AIGC 内容定期更\n定期使用后台的AIGC素材工具,上传并更新高质量的图片与视频内容。这是吸引用户点击、提升页面转化率的基础。\n✅ 亮门店:每日记得点亮门店\n需长期执行「门店点亮」动作,通过在店内设置引导物料和准备小额礼品,主动激励客户完成打卡、评价等行为,将线下流量有效反哺线上。\n✅ 冲榜单:全力抢占本地榜单席位\n榜单能增加投放转化能力(有商责退单的如果上榜单可改善平台限流情况)\n✅ 每月连续7天交易上翻,总核销金额不低于{核销金额基准}元",
  165. "fb_no_issue": "本期无明显异常",
  166. # —— 一句话结论(诊断树选出的主因)——
  167. "fb_sec_headline": "✅本期结论:",
  168. "fb_item_headline": "{结论}",
  169. "hd_R": "有违规在限流:{问题} —— 平台已在处置或限流,先处理违规项再谈增长",
  170. "hd_traffic": "卡在「流量」:{问题} —— 没有足够流量进店,后面几步都无从谈起",
  171. "hd_convert": "卡在「转化」:{问题} —— 有流量但没下单,转化环节断掉了",
  172. "hd_fulfill": "卡在「履约」:{问题} —— 成交了但没核销,钱卡在未履约这一环",
  173. "hd_repute": "卡在「口碑」:{问题} —— 口碑在拖累自然流量与转化",
  174. "hd_AMT": "核销金额没达标:{问题} —— 要靠大单上翻把金额做起来,才能保住金牌门店和流量激励",
  175. "hd_SCORE0": "卡在「口碑」:{问题} —— 还没开分,先把有效评价量做起来",
  176. "hd_OK": "本期各项指标均未触发问题",
  177. # —— 整改动作条目(来自诊断树,最多 3 条)——
  178. "fb_action_item": "✅ {动作}",
  179. "lv_R1": "预警线", "lv_R2": "一般严重线", "lv_R3": "非常严重线",
  180. "t_TD": "商责退单率{v}%超{档}",
  181. "t_TS": "投诉率{v}%超线",
  182. "t_CP": "差评率({口径}口径){v}%超线",
  183. "t_SCORE": "评分{v}(基准线{base})-低于平台准入",
  184. "t_HX": "核销率{v}%(基准线{base}%)",
  185. "t_ZERO": "本期零核销",
  186. "t_AMT": "核销金额{v}元低于基准{base}",
  187. "t_REPLY": "评价回复率{v}%偏低",
  188. "t_VISIT": "访问仅{v}次",
  189. "t_VIDEO": "本期零视频",
  190. "a_TD": "催办到店核销、主动处理退单商责, 避免平台处置",
  191. "a_TS": "24小时内响应投诉工单, 复盘服务环节",
  192. "a_CP": "处理近30天差评, 主攻优质深度好评",
  193. "a_SCORE": "引导好评+回复差评, 目标先回{base}再冲{target}",
  194. "a_HX": "电话/私信触达待核销客户, 加速到店",
  195. "a_ZERO": "检查团购在售状态与价格, 重新起量",
  196. "a_AMT": "补齐在售品结构, 冲单量",
  197. "a_REPLY": "安排专人回复全部评价",
  198. "a_VISIT": "检查POI完整度, 点亮门店+更新AIGC素材",
  199. "a_VIDEO": "后台AIGC工具定期产出图片/视频",
  200. }
  201. # 规则 key → 问题类型(筛选项/图表分类,固定不变,不受文案修改影响)
  202. RULE_CAT = {
  203. "TD": "退单率超线", "TS": "投诉率超线", "CP": "差评率超线",
  204. "SCORE": "评分不达标",
  205. "REPLY": "回复率偏低", "RVOL": "新增评价少",
  206. "DEAL0": "零成交", "CVR": "转化率低",
  207. "ZERO": "成交未核销", "HX": "核销率不达标", "AMT": "客单价偏低", "PEND": "未核销积压",
  208. "VISIT": "访问过低", "VIDEO": "视频量少", "LIT": "点亮偏低",
  209. "RANK0": "未上榜", "RANK_IN": "仅入围",
  210. }
  211. # 规则元信息:(key, tier, layer)
  212. RULES = [
  213. ("TD", "R", "repute"), ("TS", "R", "repute"), ("CP", "R", "repute"),
  214. ("SCORE", "B", "repute"), ("REPLY", "B", "repute"), ("RVOL", "T", "repute"),
  215. ("DEAL0", "B", "convert"),
  216. ("ZERO", "B", "fulfill"), ("HX", "B", "fulfill"), ("AMT", "B", "fulfill"),
  217. ("VISIT", "T", "traffic"),
  218. ("VIDEO", "T", "traffic"), ("LIT", "T", "traffic"),
  219. ("RANK0", "T", "traffic"), ("RANK_IN", "T", "traffic"),
  220. ("CVR", "T", "convert"), ("PEND", "T", "fulfill"),
  221. ]
  222. # 诊断树:漏斗顺序,取第一个命中作「主因」
  223. # "R" 表示"任一 R 级问题",命中时取违规级别最高的那条
  224. DIAGNOSIS_ORDER = ["R", "VISIT", "DEAL0", "CVR",
  225. "ZERO", "HX", "AMT", "SCORE", "REPLY", "RVOL",
  226. "VIDEO", "LIT", "PEND", "RANK0", "RANK_IN"]
  227. HEADLINE = {
  228. "R": "平台已在处置或限流,先处理违规项再谈增长",
  229. "traffic": "没有足够流量进店,后面几步都无从谈起",
  230. "convert": "有流量但没下单,转化环节断掉了",
  231. "fulfill": "成交了但没核销,钱卡在未履约这一环",
  232. "repute": "口碑在拖累自然流量与转化",
  233. "OK": "本期各项指标均未触发问题",
  234. }
  235. class _SafeDict(dict):
  236. def __missing__(self, key):
  237. return "{" + key + "}"
  238. def fmt_tpl(tpl, **vars):
  239. """模板填充:未知占位符原样保留"""
  240. try:
  241. return str(tpl).format_map(_SafeDict(**vars))
  242. except Exception:
  243. return str(tpl)
  244. def _clean_action(a):
  245. """动作条目去掉行首的 ✅/✓ 与空白,避免和「✅ {动作}」模板叠加"""
  246. return re.sub(r"^[\s\u2705\u2714\u2611\ufe0f]+", "", str(a or "")).strip()
  247. def _money(v):
  248. try:
  249. return "{:,.0f}".format(float(v or 0))
  250. except (TypeError, ValueError):
  251. return "0"
  252. def band(v, lines):
  253. """违规三级:返回值 None / R1 / R2 / R3"""
  254. if v is None:
  255. return None
  256. if v > lines[2]:
  257. return "R3"
  258. if v > lines[1]:
  259. return "R2"
  260. if v > lines[0]:
  261. return "R1"
  262. return None
  263. def diff_rate(used_coupons, intend_deal):
  264. """核销率 = 核销券数 ÷ 意向成交券数"""
  265. if intend_deal and intend_deal > 0:
  266. return used_coupons / intend_deal
  267. return None
  268. def bad_rate(reviews_new, bad_new, reviews_total, bad_total):
  269. """
  270. 差评率口径:
  271. 新增评价 ≥ cp_min_new → 新增口径,样本充足,可判三级
  272. 累计评价 ≥ cp_min_total → 累计口径,样本充足,可判三级
  273. 累计评价 > 0 → 累计口径,小样本,最高只判「预警线」
  274. 新增评价 > 0 → 新增口径,小样本,最高只判「预警线」
  275. 完全无评价 → 不判定
  276. 返回 (ratio, 口径, 是否小样本)
  277. """
  278. rn = reviews_new or 0
  279. bn = bad_new or 0
  280. rt = reviews_total or 0
  281. bt = bad_total or 0
  282. if rn >= 10:
  283. return bn / rn, "新增", False
  284. if rt >= 20:
  285. return bt / rt, "累计", False
  286. if rt > 0:
  287. return bt / rt, "累计", True
  288. if rn > 0:
  289. return bn / rn, "新增", True
  290. return None, "", False
  291. def diagnose(m, T, X=None):
  292. """
  293. 单店诊断。m 为聚合后的指标 dict,T 为阈值,X 为文案模板。
  294. 返回 dict:
  295. problems 全部命中的问题;每条含 tpl/atpl/vars,前端可直接用改后的模板重渲染
  296. primary 主因(诊断树第一个命中),没有则 None
  297. secondary 次要问题(R/B 级,最多 2 条)
  298. actions 整改动作(去重后最多 3 条)
  299. sev 严重度(0~3)
  300. tier 最高档位 R/B/T/OK
  301. headline 一句话结论
  302. """
  303. X = X or DEFAULT_TEXTS
  304. P = []
  305. def add(key, tier, layer, level, tpl, atpl, **vars):
  306. P.append({
  307. "key": key, "tier": tier, "layer": layer, "level": level,
  308. "cat": RULE_CAT.get(key, "其他"),
  309. "tpl": tpl, "atpl": atpl, "vars": vars,
  310. "text": fmt_tpl(X.get(tpl, ""), **vars) if X.get(tpl) else "",
  311. "action": fmt_tpl(X.get(atpl, ""), **vars) if X.get(atpl) else "",
  312. })
  313. visit = m.get("visit") or 0
  314. # 口径(2026-11-01):核销率分母、待核销、零成交、转化率分子统一用「门店页成交券数」
  315. intend = m.get("deal") or 0
  316. used = m.get("used_coupons") or 0
  317. amt = m.get("used_amount") or 0
  318. score = m.get("score")
  319. rn = m.get("reviews_new") or 0
  320. rt = m.get("reviews_total") or 0
  321. videos = m.get("videos") or 0
  322. lit = m.get("lit") or 0
  323. pend = max(intend - used, 0)
  324. hx = diff_rate(used, intend)
  325. cvr = (intend / visit) if visit > 0 else None
  326. # ================= 口碑层 =================
  327. b = band(m.get("return_rate"), T["tuDan"])
  328. if b:
  329. add("TD", "R", "repute", b, "t_TD", "a_TD",
  330. v="%.2f" % (m["return_rate"] * 100), 档=X["lv_" + b])
  331. b = band(m.get("complaint_rate"), T["touSu"])
  332. if b:
  333. add("TS", "R", "repute", b, "t_TS", "a_TS",
  334. v="%.2f" % (m["complaint_rate"] * 100), 档=X["lv_" + b])
  335. cp, cp_src, cp_small = bad_rate(rn, m.get("bad_new"), rt, m.get("bad_total"))
  336. b = band(cp, T["chaPing"])
  337. if b:
  338. if cp_small and b != "R1":
  339. b = "R1" # 小样本最高只判预警线
  340. add("CP", "R", "repute", b, "t_CP", "a_CP",
  341. 口径=cp_src, v="%.2f" % (cp * 100), 档=X["lv_" + b])
  342. # 评分:0 分是「未开分」,仍算不达标,但文案与动作分开(用户 2026-11-01 定)
  343. if score is not None and score < T["score_base"]:
  344. if score == 0:
  345. add("SCORE", "B", "repute", "B", "t_SCORE0", "a_SCORE0",
  346. 原因=("暂无评价" if rt == 0 else "评价量不足"))
  347. else:
  348. add("SCORE", "B", "repute", "B", "t_SCORE", "a_SCORE",
  349. v="%.1f" % score, base=T["score_base"], target=T["score_target"])
  350. reply = m.get("reply_rate")
  351. if reply is not None and rn >= T["reply_min_new"] and reply < T["reply_base"] / 100.0:
  352. add("REPLY", "B", "repute", "B", "t_REPLY", "a_REPLY",
  353. v="%.0f" % (reply * 100), 新增=rn)
  354. # 新增评价量:低于门槛 → 评价量不足,属运营提升项
  355. if rn < T["new_review_low"]:
  356. add("RVOL", "T", "repute", "T", "t_RVOL", "a_RVOL", n=rn)
  357. # ================= 转化层 =================
  358. # 四层是并行关系:转化层的问题不会压制履约层,各层各自报自己的
  359. if visit > 0 and intend == 0:
  360. add("DEAL0", "B", "convert", "B", "t_DEAL0", "a_DEAL0", v=visit)
  361. # ================= 履约层(层内按根因去重:成交未核销 > 核销率低)=================
  362. if intend > 0 and used == 0:
  363. add("ZERO", "B", "fulfill", "B", "t_ZERO", "a_ZERO", n=intend)
  364. elif intend >= T["hx_min_deal"] and hx is not None and hx < T["hx_rate_base"]:
  365. add("HX", "B", "fulfill", "B", "t_HX", "a_HX",
  366. v="%.0f" % (hx * 100), base="%.0f" % (T["hx_rate_base"] * 100))
  367. # 核销金额:独立判定(用户 2026-11-01 定),不再受核销率约束
  368. if amt < T["hx_amt_base"]:
  369. add("AMT", "B", "fulfill", "B", "t_AMT", "a_AMT",
  370. n=used, 金额=_money(amt), 客单价=_money(amt / used if used else 0))
  371. # ================= 流量层(T 级提升项)=================
  372. if visit < T["visit_low"]:
  373. add("VISIT", "T", "traffic", "T", "t_VISIT", "a_VISIT", v=visit)
  374. if videos < T["video_low"]:
  375. add("VIDEO", "T", "traffic", "T", "t_VIDEO", "a_VIDEO", v=videos)
  376. if lit < T["lit_low"] and visit > 0:
  377. add("LIT", "T", "traffic", "T", "t_LIT", "a_LIT", v=lit)
  378. # 上榜:有内容才算上榜;只有「入围」没有名次单独提示
  379. rank = (m.get("rank_note") or "").strip()
  380. ranked = rank not in ("", "-", "无")
  381. if not ranked:
  382. add("RANK0", "T", "traffic", "T", "t_RANK0", "a_RANK0")
  383. elif not re.search(r"第\s*\d+\s*名", rank):
  384. add("RANK_IN", "T", "traffic", "T", "t_RANK_IN", "a_RANK_IN",
  385. 榜=rank.split("、")[0])
  386. # ================= 转化效率 / 积压 =================
  387. if visit >= 100 and intend > 0 and cvr is not None and cvr < T["cvr_low"] / 100.0:
  388. add("CVR", "T", "convert", "T", "t_CVR", "a_CVR", v="%.1f" % (cvr * 100))
  389. if pend >= T["pend_high"]:
  390. add("PEND", "T", "fulfill", "T", "t_PEND", "a_PEND", p=pend)
  391. # ================= 诊断树:选主因 =================
  392. by_key = {}
  393. for p in P:
  394. by_key.setdefault(p["key"], p)
  395. primary = None
  396. for step in DIAGNOSIS_ORDER:
  397. if step == "R":
  398. rs = [p for p in P if p["tier"] == "R"]
  399. if rs:
  400. primary = max(rs, key=lambda x: LEVEL_WEIGHT[x["level"]])
  401. break
  402. elif step in by_key:
  403. primary = by_key[step]
  404. break
  405. rest = [p for p in P if p is not primary]
  406. secondary = sorted([p for p in rest if p["tier"] in ("R", "B")],
  407. key=lambda x: -LEVEL_WEIGHT[x["level"]])[:2]
  408. # 动作:每个问题都给对应动作(用户 2026-11-01 定:存在什么问题就给对应动作),不再限 3 条
  409. actions = []
  410. for p in ([primary] if primary else []) + [x for x in P if x is not primary]:
  411. act = _clean_action(p["action"])
  412. if act and act not in actions:
  413. actions.append(act)
  414. sev = max((LEVEL_WEIGHT[p["level"]] for p in P), default=0)
  415. if any(p["tier"] == "R" for p in P):
  416. tier = "R"
  417. elif any(p["tier"] == "B" for p in P):
  418. tier = "B"
  419. elif P:
  420. tier = "T"
  421. else:
  422. tier = "OK"
  423. if primary is None:
  424. hd_key = "hd_OK"
  425. headline = X.get(hd_key) or HEADLINE["OK"]
  426. else:
  427. # 结论句回退链:规则专属 → 模板专属(区分「未开分」)→ 该层通用 → OK
  428. cands = ["hd_" + primary["key"]]
  429. tpl = primary.get("tpl") or ""
  430. if tpl.startswith("t_"):
  431. cands.append("hd_" + tpl[2:])
  432. cands.append("hd_R" if primary["tier"] == "R" else "hd_" + primary["layer"])
  433. cands.append("hd_OK")
  434. hd_key = next((c for c in cands if c in X), "hd_OK")
  435. headline = fmt_tpl(X[hd_key], 问题=primary["text"])
  436. return {
  437. "problems": P,
  438. "primary": primary,
  439. "secondary": secondary,
  440. "actions": actions,
  441. "sev": sev,
  442. "tier": tier,
  443. "headline": headline,
  444. "hd_key": hd_key,
  445. }
  446. def detect(m, T, X=None):
  447. """兼容旧接口:返回 (flags, sev),flags 为 [level, 文案, 建议动作, 规则key]"""
  448. d = diagnose(m, T, X)
  449. flags = [[p["level"], p["text"], p["action"], p["key"]] for p in d["problems"]]
  450. return flags, d["sev"]
  451. def cat_of(label):
  452. """按文案反查问题类型(文案被改过时不可靠,筛选用 RULE_CAT[key])"""
  453. for prefix, key in (("商责退单率", "TD"), ("投诉率", "TS"), ("差评率", "CP"),
  454. ("评分", "SCORE"), ("未开分", "SCORE0"), ("核销率", "HX"),
  455. ("成交", "ZERO"), ("核销", "AMT"), ("回复率", "REPLY"),
  456. ("访问", "VISIT"), ("视频", "VIDEO"), ("点亮", "LIT"),
  457. ("转化", "CVR"), ("积压", "PEND")):
  458. if label.startswith(prefix):
  459. return RULE_CAT[key]
  460. return "其他"
  461. def validate(T):
  462. """校验阈值合法性,返回错误列表(空=通过)"""
  463. errs = []
  464. try:
  465. for k in ("score_base", "score_target", "hx_rate_base", "hx_rate_target",
  466. "hx_amt_base", "reply_base", "visit_low", "cvr_low", "pend_high",
  467. "hx_min_deal", "amt_min_coupons", "cp_min_new", "cp_min_total",
  468. "reply_min_new"):
  469. if k in T:
  470. T[k] = float(T[k])
  471. for k in ("tuDan", "touSu", "chaPing"):
  472. T[k] = [float(x) for x in T[k]]
  473. a = T[k]
  474. if not (a[0] > 0 and a[1] > a[0] and a[2] > a[1]):
  475. errs.append("三级阈值需递增")
  476. if T["score_base"] >= T["score_target"]:
  477. errs.append("评分目标值应高于考核基准")
  478. if T["hx_rate_base"] >= T["hx_rate_target"]:
  479. errs.append("核销率目标值应高于考核基准")
  480. if T["hx_amt_base"] < 0 or T["visit_low"] < 0:
  481. errs.append("金额/访问线不能为负")
  482. if T.get("cvr_low", 0) < 0 or T.get("pend_high", 0) < 0:
  483. errs.append("转化率/积压线不能为负")
  484. if T.get("cp_min_new", 0) <= 0 or T.get("cp_min_total", 0) <= 0:
  485. errs.append("差评率最小样本量需大于 0")
  486. if T.get("hx_min_deal", 0) < 1 or T.get("amt_min_coupons", 0) < 1:
  487. errs.append("核销率最小成交券数需 ≥ 1")
  488. except (TypeError, ValueError, KeyError):
  489. errs.append("阈值格式不正确")
  490. return errs