detect.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. # -*- coding: utf-8 -*-
  2. """
  3. 门店月度检测引擎 v0.1 —— 基于飞书「门店考核指标」官方基准线
  4. 输入: 门店数据_10月.csv (每月从飞书表导出/更新)
  5. 输出: 04_输出/门店检测报告_<月份>.html + 门店检测清单_<月份>.csv
  6. 阈值集中在 THRESH, 与飞书《门店考核指标》表一致; 口径如有调整, 只改这里。
  7. """
  8. import csv
  9. import os
  10. import sys
  11. BASE = os.path.dirname(os.path.abspath(__file__))
  12. OUT = os.path.join(os.path.dirname(BASE), '04_输出')
  13. # ---- 阈值(来自飞书「门店考核指标」表) ----
  14. THRESH = dict(
  15. score_base=4.3, score_target=4.5, # 门店评分: 基准4.3, 目标高于4.5
  16. hx_rate_base=0.40, hx_rate_target=0.60, # 核销率: 基准40%, 目标60%
  17. hx_amt_base=12000, # 核销金额: 基准12000
  18. tuDan=[0.0127, 0.0318, 0.0635], # 商责退单率: 预警/一般/非常严重(小数)
  19. touSu=[0.0011, 0.0028, 0.0056], # 投诉率(小数)
  20. chaPing=[0.0066, 0.0166, 0.0331], # 差评率(小数)
  21. reply_base=50, # 评价回复率(%)
  22. visit_low=100, # 月访问过低线
  23. )
  24. LV = {'R3': 3, 'R2': 2, 'R1': 1, 'B': 0.6, 'T': 0.3}
  25. def f(v, pct=False):
  26. try:
  27. x = float(str(v).replace('%', '').replace(',', ''))
  28. return x / 100 if pct else x
  29. except (ValueError, TypeError):
  30. return None
  31. def band(v, lines):
  32. """返回违规等级 R1/R2/R3 或 None"""
  33. if v is None:
  34. return None
  35. if v > lines[2]:
  36. return 'R3'
  37. if v > lines[1]:
  38. return 'R2'
  39. if v > lines[0]:
  40. return 'R1'
  41. return None
  42. def detect(rows):
  43. out, seen, id_conflict = [], {}, []
  44. for r in rows:
  45. sid = r['门店ID'].strip()
  46. if sid in seen:
  47. if seen[sid] != r['门店名称']:
  48. id_conflict.append((sid, seen[sid], r['门店名称']))
  49. continue # 重复行去重(保留首行)
  50. seen[sid] = r['门店名称']
  51. g = {k: f(r.get(k, ''), pct='率' in k or k == '评价回复率') for k in (
  52. '门店页访问次数', '门店页成交券数', '门店核销金额', '门店核销券数',
  53. '门店意向成交券数', '门店评分', '新增评价数', '新增中差评数',
  54. '累计评价数', '累计中差评数', '评价回复率', '经营风险投诉率',
  55. '经营风险商责退单率', '新发布门店关联视频数', '门店收藏数')}
  56. if g['门店意向成交券数'] and g['门店意向成交券数'] > 0:
  57. hx_rate = (g['门店核销券数'] or 0) / g['门店意向成交券数']
  58. else:
  59. hx_rate = None
  60. if (g['新增评价数'] or 0) >= 10:
  61. cp_rate = (g['新增中差评数'] or 0) / g['新增评价数']
  62. cp_src = '新增'
  63. elif (g['累计评价数'] or 0) >= 20:
  64. cp_rate = (g['累计中差评数'] or 0) / g['累计评价数']
  65. cp_src = '累计'
  66. else:
  67. cp_rate, cp_src = None, '' # 样本太少, 不判差评率, 避免小样本误报
  68. flags = []
  69. lv = band(g['经营风险商责退单率'], THRESH['tuDan'])
  70. if lv:
  71. lv_name = {'R1': '预警线', 'R2': '一般严重线', 'R3': '非常严重线'}[lv]
  72. flags.append((lv, f"商责退单率{g['经营风险商责退单率'] * 100:.2f}%超{lv_name}",
  73. '催办到店核销、主动处理退单商责, 避免平台处置'))
  74. lv = band(g['经营风险投诉率'], THRESH['touSu'])
  75. if lv:
  76. flags.append((lv, f"投诉率{g['经营风险投诉率'] * 100:.2f}%超线", '24小时内响应投诉工单, 复盘服务环节'))
  77. lv = band(cp_rate, THRESH['chaPing'])
  78. if lv:
  79. flags.append((lv, f"差评率({cp_src}口径){cp_rate * 100:.2f}%超线",
  80. '处理近30天差评, 主攻优质深度好评'))
  81. if g['门店评分'] is not None and g['门店评分'] < THRESH['score_base']:
  82. flags.append(('B', f"评分{g['门店评分']:.1f}低于考核基准{THRESH['score_base']}",
  83. '引导好评+回复差评, 目标先回4.3再冲4.5'))
  84. if hx_rate is not None and hx_rate < THRESH['hx_rate_base']:
  85. flags.append(('B', f"核销率{hx_rate * 100:.0f}%低于基准{THRESH['hx_rate_base'] * 100:.0f}%",
  86. '电话/私信触达待核销客户, 加速到店'))
  87. if (g['门店核销金额'] or 0) < THRESH['hx_amt_base']:
  88. if (g['门店核销券数'] or 0) == 0:
  89. flags.append(('B', '本月零核销', '检查团购在售状态与价格, 重新起量'))
  90. else:
  91. flags.append(('B', f"核销金额{g['门店核销金额']:.0f}元低于基准{THRESH['hx_amt_base']}",
  92. '补齐在售品结构, 冲单量'))
  93. if (g['评价回复率'] is not None and g['评价回复率'] < THRESH['reply_base'] / 100):
  94. flags.append(('T', f"评价回复率{g['评价回复率'] * 100:.0f}%偏低", '安排专人回复全部评价'))
  95. if (g['门店页访问次数'] or 0) < THRESH['visit_low']:
  96. flags.append(('T', f"月访问仅{g['门店页访问次数']:.0f}次", '检查POI完整度, 点亮门店+更新AIGC素材'))
  97. if (g['新发布门店关联视频数'] or 0) == 0:
  98. flags.append(('T', '本月零视频', '后台AIGC工具定期产出图片/视频'))
  99. sev = max((LV[lv] for lv, _, _ in flags), default=0)
  100. out.append(dict(门店ID=sid, 门店名称=r['门店名称'], 城市=r['所在城市'],
  101. 评分=g['门店评分'], 核销率=hx_rate, 核销金额=g['门店核销金额'] or 0,
  102. 核销券数=g['门店核销券数'] or 0,
  103. 商责退单率=g['经营风险商责退单率'] or 0, 投诉率=g['经营风险投诉率'] or 0,
  104. 差评率=cp_rate, 差评口径=cp_src, 回复率=g['评价回复率'],
  105. 月访问=g['门店页访问次数'] or 0, 新视频=g['新发布门店关联视频数'] or 0,
  106. flags=flags, sev=sev))
  107. out.sort(key=lambda x: (-x['sev'], x['核销金额']))
  108. return out, id_conflict
  109. def actions(m):
  110. acts, seen = [], set()
  111. for _, _, a in m['flags']:
  112. if a not in seen:
  113. seen.add(a)
  114. acts.append(a)
  115. return acts
  116. CSS = open(os.path.join(BASE, 'report_style.css'), encoding='utf-8').read() \
  117. if os.path.exists(os.path.join(BASE, 'report_style.css')) else """
  118. *{box-sizing:border-box;margin:0;padding:0}
  119. body{background:#0f1115;color:#e6e8ec;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
  120. font-size:14px;line-height:1.65;padding:36px 40px 64px}
  121. .wrap{max-width:1180px;margin:0 auto}
  122. h1{font-size:22px;font-weight:600}.sub{color:#8b93a1;font-size:13px;margin-top:8px}
  123. .kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:26px 0 30px}
  124. .kpi{background:#171a21;border:1px solid #252a34;border-radius:12px;padding:16px 18px}
  125. .kpi .v{font-size:24px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
  126. .kpi .l{color:#8b93a1;font-size:12px;margin-top:2px}
  127. .kpi.warn .v{color:#ff8585}
  128. h2{font-size:15px;font-weight:600;margin:34px 0 14px;display:flex;align-items:center;gap:8px}
  129. h2 .bar{width:3px;height:14px;background:#6aa9ff;border-radius:2px}
  130. table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}
  131. th{text-align:left;color:#8b93a1;font-weight:500;font-size:12px;padding:9px 10px;border-bottom:1px solid #2a2f3a;white-space:nowrap}
  132. td{padding:10px;border-bottom:1px solid #1d212a;vertical-align:top}
  133. tr:hover td{background:#151920}
  134. .tag{display:inline-block;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:500;margin:2px 4px 2px 0;white-space:nowrap}
  135. .t-R3{background:rgba(255,60,60,.2);color:#ff6b6b}
  136. .t-R2{background:rgba(255,107,107,.14);color:#ff8585}
  137. .t-R1{background:rgba(240,168,60,.16);color:#f0b45c}
  138. .t-B{background:rgba(109,140,255,.14);color:#8fa8ff}
  139. .t-T{background:rgba(140,148,163,.14);color:#9aa3b2}
  140. .cards{display:grid;gap:10px}
  141. .card{background:#171a21;border:1px solid #252a34;border-left:3px solid #2f3745;border-radius:10px;padding:14px 18px}
  142. .card.R3{border-left-color:#ff3c3c}.card.R2{border-left-color:#ff6b6b}.card.R1{border-left-color:#f0a83c}
  143. .card .h{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  144. .card .nm{font-weight:600;font-size:14px}
  145. .card .city{color:#6f7787;font-size:12px}
  146. .card .msg{color:#a8b0be;font-size:13px;margin-top:6px}
  147. .card .act{color:#5fd6ae;font-size:12px;margin-top:4px}
  148. .card .nums{color:#6f7787;font-size:12px;margin-top:6px;font-variant-numeric:tabular-nums}
  149. .note{color:#767e8c;font-size:12px;line-height:1.9;margin-top:14px}
  150. .note b{color:#9aa3b2;font-weight:500}
  151. """
  152. def render(metrics, conflicts, month):
  153. def cnt(p):
  154. return sum(1 for m in metrics if any(lv == p or (p == 'R' and lv in ('R1', 'R2', 'R3')) for lv, _, _ in m['flags']))
  155. kpis = [('检测门店', f'{len(metrics)} 家'),
  156. ('超违规预警线(退单/投诉/差评)', f'{cnt("R")} 家'),
  157. ('评分低于基准4.3', f'{sum(1 for m in metrics if m["评分"] is not None and m["评分"] < THRESH["score_base"])} 家'),
  158. ('核销率低于基准40%', f'{sum(1 for m in metrics if m["核销率"] is not None and m["核销率"] < THRESH["hx_rate_base"])} 家'),
  159. ('本月零核销', f'{sum(1 for m in metrics if m["核销金额"] == 0)} 家')]
  160. worst = [m for m in metrics if m['sev'] >= LV['R1']][:20]
  161. cards = []
  162. for m in worst:
  163. top = max(m['flags'], key=lambda x: LV[x[0]])
  164. cards.append(f"""<div class="card {top[0]}">
  165. <div class="h"><span class="nm">{m['门店名称']}</span><span class="city">{m['城市']}</span>
  166. {''.join(f'<span class="tag t-{lv}">{lb}</span>' for lv, lb, _ in m['flags'][:4])}</div>
  167. <div class="msg">{actions(m)[0]}</div>
  168. <div class="nums">评分 {m['评分']} · 核销率 {f'{m["核销率"]*100:.0f}%' if m['核销率'] is not None else '-'} · 核销金额 {m['核销金额']:.0f} 元 · 退单率 {m['商责退单率'] * 100:.2f}% · 月访问 {m['月访问']:.0f}</div>
  169. </div>""")
  170. trs = []
  171. for m in metrics[:150]:
  172. tags = ''.join(f'<span class="tag t-{lv}">{lb}</span>' for lv, lb, _ in m['flags'])
  173. trs.append(f"""<tr><td>{m['门店名称']}</td><td>{m['城市']}</td>
  174. <td>{m['评分'] if m['评分'] is not None else '-'}</td>
  175. <td>{f'{m["核销率"]*100:.0f}%' if m['核销率'] is not None else '-'}</td>
  176. <td>{m['核销金额']:.0f}</td><td>{m['商责退单率'] * 100:.2f}%</td><td>{tags}</td></tr>""")
  177. conf_html = ''
  178. if conflicts:
  179. rows = ''.join(f'<div>· {sid}: {n1} / {n2} —— 两个门店共用一个ID, 需在主数据里核实</div>'
  180. for sid, n1, n2 in conflicts)
  181. conf_html = f'<h2><span class="bar"></span>主数据风险:同 ID 不同名</h2><div class="cards"><div class="card warn">{rows}</div></div>'
  182. return f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
  183. <meta name="viewport" content="width=device-width,initial-scale=1">
  184. <title>门店月度检测报告 {month}</title><style>{CSS}</style></head><body><div class="wrap">
  185. <h1>门店月度检测报告 · {month}</h1>
  186. <div class="sub">检测规则来自《门店考核指标》官方基准线 · 覆盖 {len(metrics)} 家门店 · 完整清单见同名 CSV</div>
  187. <div class="kpis">{''.join(f'<div class="kpi{" warn" if i in (1,) else ""}"><div class="v">{v}</div><div class="l">{k}</div></div>' for i, (k, v) in enumerate(kpis))}</div>
  188. <h2><span class="bar"></span>优先处理:超违规线门店 TOP{len(cards)}</h2>
  189. <div class="cards">{''.join(cards)}</div>
  190. {conf_html}
  191. <h2><span class="bar"></span>检测明细(前 150 行,按严重度排序)</h2>
  192. <table><thead><tr><th>门店</th><th>城市</th><th>评分</th><th>核销率</th><th>核销金额</th><th>退单率</th><th>触发问题</th></tr></thead>
  193. <tbody>{''.join(trs)}</tbody></table>
  194. <div class="note">
  195. <b>规则口径</b> 核销率 = 核销券数 ÷ 意向成交券数;差评率优先用新增口径(本月新增中差评÷新增评价),本月无新增评价时回退累计口径并标注。<br>
  196. <b>等级说明</b> 红色=超出违规线(退单/投诉/差评三线),蓝色=低于考核基准,灰色=提升项。<br>
  197. <b>主数据</b> 本月去重 {sum(1 for _ in metrics)} 家,去重依据门店ID;同ID不同名的冲突单列在报告里,需要人工核实。
  198. </div></div></body></html>"""
  199. def main():
  200. args = sys.argv[1:]
  201. src = args[0] if args and args[0].endswith('.csv') else os.path.join(BASE, '门店数据_10月.csv')
  202. month = '2026-10'
  203. if '--month' in args:
  204. month = args[args.index('--month') + 1]
  205. rows = list(csv.DictReader(open(src, encoding='utf-8-sig')))
  206. metrics, conflicts = detect(rows)
  207. os.makedirs(OUT, exist_ok=True)
  208. tag = month.replace('_', '')
  209. html = os.path.join(OUT, f'门店检测报告_{tag}.html')
  210. csvout = os.path.join(OUT, f'门店检测清单_{tag}.csv')
  211. with open(html, 'w', encoding='utf-8') as fp:
  212. fp.write(render(metrics, conflicts, month))
  213. with open(csvout, 'w', encoding='utf-8-sig', newline='') as fp:
  214. w = csv.writer(fp)
  215. w.writerow(['门店ID', '门店名称', '城市', '评分', '核销率', '核销金额', '商责退单率',
  216. '投诉率', '差评率', '差评口径', '评价回复率', '月访问', '本月新视频', '触发问题', '建议动作'])
  217. for m in metrics:
  218. w.writerow([m['门店ID'], m['门店名称'], m['城市'],
  219. m['评分'] if m['评分'] is not None else '',
  220. f"{m['核销率']:.4f}" if m['核销率'] is not None else '',
  221. f"{m['核销金额']:.2f}", f"{m['商责退单率'] * 100:.2f}%", f"{m['投诉率'] * 100:.2f}%",
  222. f"{m['差评率'] * 100:.2f}" if m['差评率'] is not None else '', m['差评口径'],
  223. f"{m['回复率'] * 100:.0f}" if m['回复率'] is not None else '',
  224. f"{m['月访问']:.0f}", f"{m['新视频']:.0f}",
  225. ' | '.join(f'{lb}' for _, lb, _ in m['flags']),
  226. ' ;'.join(actions(m))])
  227. print(f'报告: {html}')
  228. print(f'清单: {csvout}')
  229. print(f'检测 {len(metrics)} 家, 红线 {sum(1 for m in metrics if m["sev"] >= 1)} 家, 同ID不同名冲突 {len(conflicts)} 组')
  230. if __name__ == '__main__':
  231. main()