gen_rules_doc.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. # -*- coding: utf-8 -*-
  2. """把当前生效的检测规则导出成本地文档(DOCX + Markdown)
  3. 用法:python3 tools/gen_rules_doc.py
  4. """
  5. import os, sys, json
  6. from datetime import datetime
  7. BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  8. sys.path.insert(0, os.path.join(BASE, "backend"))
  9. os.chdir(BASE)
  10. import re
  11. import rules, db, codesync
  12. def clean(a):
  13. """去掉动作行首的 ✅/✓,与工作台展示保持一致"""
  14. return re.sub(r"^[\s\u2705\u2714\u2611\ufe0f]+", "", str(a or "")).strip()
  15. LAYER = [("traffic", "① 流量层 · 能不能被看到"),
  16. ("convert", "② 转化层 · 看到了有没有下单"),
  17. ("fulfill", "③ 履约层 · 下单了有没有核销"),
  18. ("repute", "④ 口碑层 · 体验好不好")]
  19. TIER = {"R": "违规红线", "B": "考核未达标", "T": "运营提升", "N": "样本不足"}
  20. COND = {
  21. "TD": "商责退单率 > 1.27% / 3.18% / 6.35%(预警 / 一般严重 / 非常严重)",
  22. "TS": "投诉率 > 0.11% / 0.28% / 0.56%(三级)",
  23. "CP": "新增评价 ≥10 → 新增口径;否则累计评价 ≥20 → 累计口径;都不足仍判定但最高只到预警线;超 0.66% / 1.66% / 3.31%",
  24. "SCORE":"评分 < 4.3(0 分按「未开分」处理,文案与动作单独走)",
  25. "REPLY":"新增评价 ≥1 且 评价回复率 < 50%",
  26. "RVOL": "新增评价数 < 10",
  27. "DEAL0":"访问 > 0 且 门店页成交券数 = 0",
  28. "ZERO": "门店页成交券数 > 0 且 核销券数 = 0(履约层内命中此项则不再判「核销率低」)",
  29. "HX": "门店页成交券数 ≥ 5 且 核销率 < 40%",
  30. "AMT": "核销金额 < 10,000 元(独立判定,不受核销率约束)",
  31. "VISIT":"访问次数 < 100(含 0 访问)",
  32. "VIDEO":"新发布门店关联视频数 < 10",
  33. "LIT": "门店点亮数 < 10 且 访问 > 0",
  34. "CVR": "访问 ≥100 且 成交券数 > 0 且 成交券数 ÷ 访问 < 10%",
  35. "PEND": "估算未核销券(门店页成交券数 − 核销券数)≥ 10 张",
  36. "RANK0": "上榜信息为空 / 「-」,即好评榜、人气榜均未进入",
  37. "RANK_IN": "上榜信息只有「入围XX榜」,没有「第 N 名」",
  38. }
  39. TREE = [
  40. ("① 有 R 级违规(退单 / 投诉 / 差评)", "平台已在处置或限流", "先止血:处理违规项,再谈增长"),
  41. ("② 零访问 / 访问过低", "没有流量进来,后面几步都无从谈起", "先修曝光(点亮 + 素材 + 榜单)"),
  42. ("③ 零成交 / 转化率低", "有流量但没下单", "修转化(品项、价格、主图)"),
  43. ("④ 成交未核销 / 核销率低 / 核销金额低", "客户买了不到店,或到店不核销", "修履约(集中邀约 + 核销激励)"),
  44. ("⑤ 评分不达标 / 回复率低", "口碑拖累自然流量与转化", "修口碑(处理差评 + 专人回复)"),
  45. ("⑥ 以上都不命中", "本期达标", "保持动作,关注冲刺目标"),
  46. ]
  47. OUTPUT = [
  48. ("一句话结论", "按诊断树选出的主因生成,如「卡在「履约」:核销率 11%(基准线 40%)」"),
  49. ("主因", "诊断树从上往下第一个命中的问题"),
  50. ("全部问题", "命中几个列几个,不再截断"),
  51. ("整改动作", "每个问题一条对应动作,去重后输出"),
  52. ]
  53. def collect():
  54. T = db.get_thresholds()
  55. X = db.get_texts()
  56. period = db.latest_period()
  57. rows = db.aggregate_period(period)
  58. hits, sample = {}, {}
  59. for m in rows:
  60. d = rules.diagnose(m, T, X)
  61. for p in d["problems"]:
  62. hits[p["key"]] = hits.get(p["key"], 0) + 1
  63. sample.setdefault(p["key"], p)
  64. groups = []
  65. for key, name in LAYER:
  66. items = []
  67. for rk, tier, layer in rules.RULES:
  68. if layer != key:
  69. continue
  70. p = sample.get(rk)
  71. items.append({
  72. "id": rk, "tier": tier, "name": rules.RULE_CAT.get(rk, rk),
  73. "cond": COND.get(rk, ""),
  74. "problem": (p["text"] if p else (X.get("t_" + rk) or "")),
  75. "action": clean(p["action"] if p else (X.get("a_" + rk) or "")),
  76. "hits": hits.get(rk, 0),
  77. })
  78. groups.append((name, items))
  79. params = [(k, T.get(k), codesync.get_factory().get(k)) for k in rules.DEFAULT_THRESHOLDS]
  80. return {"period": period, "stores": len(rows), "groups": groups, "params": params,
  81. "tree": TREE, "output": OUTPUT}
  82. def build_md(d):
  83. L = ["# 门店检测工作台 · 检测规则说明", "",
  84. "> 数据周期 %s · %d 家门店 · 导出时间 %s" %
  85. (d["period"], d["stores"], datetime.now().strftime("%Y-%m-%d %H:%M")), ""]
  86. L += ["## 一、核心口径", "",
  87. "- 核销率 = 核销券数 ÷ **门店页成交券数**", "- 待核销 = 门店页成交券数 − 核销券数(估算,未扣退款)",
  88. "- 差评率:新增评价 ≥10 用新增口径;否则累计 ≥20 用累计口径;都不足仍判定但最高只到预警线",
  89. "- 评分 0 分 = 未开分,仍计入「评分不达标」", "- 评价回复率:只在有新增评价时判定",
  90. "- **四层并行**:流量 / 转化 / 履约 / 口碑各自独立判定、互不压制;只有履约层内部「成交未核销 → 核销率低」按根因去重",
  91. ""]
  92. L += ["## 二、规则明细", ""]
  93. for name, items in d["groups"]:
  94. L += ["### " + name, "", "| ID | 档位 | 指标 | 判定条件 | 问题文案 | 整改动作 | 命中 |",
  95. "|---|---|---|---|---|---|---|"]
  96. for it in items:
  97. L.append("| %s | %s | %s | %s | %s | %s | %d 家 |" %
  98. (it["id"], TIER.get(it["tier"], it["tier"]), it["name"], it["cond"],
  99. it["problem"], it["action"].replace("\n", "<br>"), it["hits"]))
  100. L.append("")
  101. L += ["## 三、可调阈值", "", "| 参数 | 当前值 | 出厂值 |", "|---|---|---|"]
  102. for k, cur, fac in d["params"]:
  103. L.append("| %s | %s | %s |" % (k, cur, fac))
  104. L += ["", "## 四、诊断树(主因判定顺序)", "", "| 顺序 | 命中条件 | 含义 | 处理方向 |", "|---|---|---|---|"]
  105. for i, (a, b, c) in enumerate(d["tree"], 1):
  106. L.append("| %d | %s | %s | %s |" % (i, a, b, c))
  107. L += ["", "## 五、输出结构", "", "| 字段 | 说明 |", "|---|---|"]
  108. for a, b in d["output"]:
  109. L.append("| %s | %s |" % (a, b))
  110. return "\n".join(L)
  111. def build_docx(d, path):
  112. from docx import Document
  113. from docx.shared import Pt, Cm, RGBColor
  114. from docx.enum.text import WD_ALIGN_PARAGRAPH
  115. from docx.oxml.ns import qn
  116. from docx.enum.section import WD_ORIENT
  117. from docx.oxml import OxmlElement
  118. doc = Document()
  119. sec = doc.sections[0]
  120. # 横向 A4:规则表列多,竖版会把「整改动作」挤成竖条
  121. sec.orientation = WD_ORIENT.LANDSCAPE
  122. sec.page_width, sec.page_height = Cm(29.7), Cm(21.0)
  123. sec.left_margin = sec.right_margin = Cm(1.5)
  124. sec.top_margin = sec.bottom_margin = Cm(1.5)
  125. st = doc.styles["Normal"]
  126. st.font.name = "PingFang SC"
  127. st.font.size = Pt(10)
  128. st._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
  129. st.paragraph_format.space_after = Pt(4)
  130. st.paragraph_format.line_spacing = 1.35
  131. def cn(run, size=None, bold=None, color=None):
  132. run.font.name = "PingFang SC"
  133. run._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
  134. if size: run.font.size = Pt(size)
  135. if bold is not None: run.font.bold = bold
  136. if color: run.font.color.rgb = RGBColor(*color)
  137. t = doc.add_paragraph()
  138. cn(t.add_run("门店检测工作台 · 检测规则说明"), 18, True, (0x21, 0x1a, 0x10))
  139. t.alignment = WD_ALIGN_PARAGRAPH.LEFT
  140. meta = doc.add_paragraph()
  141. cn(meta.add_run("数据周期 %s · %d 家门店 · 导出时间 %s" %
  142. (d["period"], d["stores"], datetime.now().strftime("%Y-%m-%d %H:%M"))), 9, False, (0x93, 0x87, 0x6f))
  143. def h(text, size=13):
  144. p = doc.add_paragraph()
  145. p.paragraph_format.space_before = Pt(14)
  146. p.paragraph_format.space_after = Pt(6)
  147. cn(p.add_run(text), size, True, (0x21, 0x1a, 0x10))
  148. return p
  149. def table(headers, rows, widths):
  150. tb = doc.add_table(rows=1, cols=len(headers))
  151. tb.style = "Table Grid"
  152. tb.autofit = False
  153. # 固定布局,否则 Word 会按内容重新分配列宽
  154. tblPr = tb._tbl.tblPr
  155. layout = OxmlElement("w:tblLayout")
  156. layout.set(qn("w:type"), "fixed")
  157. tblPr.append(layout)
  158. tblW = OxmlElement("w:tblW")
  159. tblW.set(qn("w:type"), "dxa")
  160. tblW.set(qn("w:w"), str(int(sum(widths) * 567)))
  161. tblPr.append(tblW)
  162. for i, w in enumerate(widths):
  163. tb.columns[i].width = Cm(w)
  164. for cell in tb.columns[i].cells:
  165. cell.width = Cm(w)
  166. for i, hd in enumerate(headers):
  167. c = tb.rows[0].cells[i]
  168. c.text = ""
  169. cn(c.paragraphs[0].add_run(hd), 9, True)
  170. for row in rows:
  171. cells = tb.add_row().cells
  172. for i, v in enumerate(row):
  173. cells[i].width = Cm(widths[i])
  174. cells[i].text = ""
  175. p = cells[i].paragraphs[0]
  176. p.paragraph_format.space_after = Pt(2)
  177. cn(p.add_run(str(v)), 8.5)
  178. return tb
  179. # 一、口径
  180. h("一、核心口径")
  181. for line in [
  182. "核销率 = 核销券数 ÷ 门店页成交券数(分母为 0 时不判定)",
  183. "待核销 = 门店页成交券数 − 核销券数(估算值,未扣退款,仅作邀约优先级参考)",
  184. "差评率:新增评价 ≥10 用新增口径;否则累计评价 ≥20 用累计口径;都不足仍判定,但最高只到预警线",
  185. "评分:取周期内最近一次有值;0 分视为「未开分」,仍计入「评分不达标」",
  186. "评价回复率:只在有新增评价时判定(没有新评价就没有回复义务)",
  187. "四层并行:流量 / 转化 / 履约 / 口碑各自独立判定、互不压制;只有履约层内部「成交未核销 → 核销率低」按根因去重",
  188. ]:
  189. p = doc.add_paragraph(style="List Bullet")
  190. cn(p.add_run(line), 9.5)
  191. # 二、规则明细
  192. h("二、规则明细(共 %d 条)" % sum(len(i) for _, i in d["groups"]))
  193. p0 = doc.add_paragraph()
  194. cn(p0.add_run("说明:「问题文案」列给的是按当前数据渲染出的真实示例,实际输出时会替换成各门店自己的数值。"),
  195. 8.5, False, (0x93, 0x87, 0x6f))
  196. for name, items in d["groups"]:
  197. p = doc.add_paragraph()
  198. p.paragraph_format.space_before = Pt(10)
  199. cn(p.add_run(name), 10.5, True, (0x8a, 0x6a, 0x2f))
  200. table(["ID", "档位", "指标", "判定条件", "问题文案", "整改动作", "命中"],
  201. [[it["id"], TIER.get(it["tier"], it["tier"]), it["name"], it["cond"],
  202. it["problem"], it["action"].replace("\n", " / "), "%d 家" % it["hits"]]
  203. for it in items],
  204. [1.4, 1.9, 2.4, 7.0, 5.4, 6.2, 1.4])
  205. # 三、阈值
  206. h("三、可调阈值(%d 项)" % len(d["params"]))
  207. doc.add_paragraph().add_run()
  208. p = doc.add_paragraph()
  209. cn(p.add_run("在【指标配置】面板改数字即可,保存时会同步写回代码默认值。"), 9, False, (0x93, 0x87, 0x6f))
  210. table(["参数", "当前值", "出厂值"],
  211. [[k, str(cur), str(fac)] for k, cur, fac in d["params"]],
  212. [9.0, 4.0, 4.0])
  213. # 四、诊断树
  214. h("四、诊断树(主因判定顺序)")
  215. table(["顺序", "命中条件", "含义", "处理方向"],
  216. [[str(i), a, b, c] for i, (a, b, c) in enumerate(d["tree"], 1)],
  217. [1.2, 9.5, 7.0, 8.0])
  218. # 五、输出
  219. h("五、每家门店的输出结构")
  220. table(["字段", "说明"], [[a, b] for a, b in d["output"]], [3.2, 14.2])
  221. doc.save(path)
  222. return path
  223. if __name__ == "__main__":
  224. d = collect()
  225. out = os.path.join(os.path.dirname(BASE), "检测规则说明_%s" % datetime.now().strftime("%Y%m%d"))
  226. md = out + ".md"
  227. open(md, "w", encoding="utf-8").write(build_md(d))
  228. print(md)
  229. docx_path = out + ".docx"
  230. build_docx(d, docx_path)
  231. print(docx_path)