소스 검색

门店检测工作台 v2.3:规则引擎、账号权限、服务器部署

后端(Python 标准库,零依赖)
- rules.py        17 条检测规则,四层并行,阈值/文案单一来源
- db.py           SQLite 存储,快照式近30天数据模型
- ingest.py       CSV/Excel 导入,自动识别统计窗口
- server.py       HTTP 服务 + 账号鉴权 + 权限校验
- auth.py         PBKDF2 密码哈希、会话管理、角色权限表
- codesync.py     面板改阈值时同步写回代码默认值
- xlsx.py         零依赖 xlsx 读取

前端(原生 JS,无构建)
- 看板、门店抽屉、单店报告、指标/文案配置、账号管理、导入

功能要点
- 核销率分母统一用门店页成交券数
- 近30天滚动快照,每天 01:00 自动同步(依赖 ego-browser)
- 三级角色:管理员 / 运营 / 查看
- 导出为生意经原始文件,不含计算列

部署
- start.sh + deploy/store-ops.service + DEPLOY.md
海棉 1 주 전
커밋
4406a689f1
41개의 변경된 파일과 9748개의 추가작업 그리고 0개의 파일을 삭제
  1. 24 0
      .gitignore
  2. 242 0
      store-ops/DEPLOY.md
  3. 297 0
      store-ops/README.md
  4. 79 0
      store-ops/backend/auth.py
  5. 130 0
      store-ops/backend/codesync.py
  6. 645 0
      store-ops/backend/db.py
  7. 392 0
      store-ops/backend/ingest.py
  8. 543 0
      store-ops/backend/rules.py
  9. 852 0
      store-ops/backend/server.py
  10. 196 0
      store-ops/backend/xlsx.py
  11. 34 0
      store-ops/deploy/store-ops.service
  12. 1694 0
      store-ops/frontend/app.js
  13. 180 0
      store-ops/frontend/index.html
  14. 116 0
      store-ops/frontend/login.html
  15. 163 0
      store-ops/frontend/report.css
  16. 402 0
      store-ops/frontend/style.css
  17. 34 0
      store-ops/frontend/vendor/echarts.min.js
  18. 43 0
      store-ops/start.sh
  19. 52 0
      store-ops/sync/com.yhm.lifesync.plist
  20. 160 0
      store-ops/sync/life_sync.mjs
  21. 40 0
      store-ops/sync/run_sync.sh
  22. 257 0
      store-ops/tools/gen_rules_doc.py
  23. 17 0
      store-ops/启动工作台.bat
  24. 64 0
      store-ops/启动工作台.command
  25. 32 0
      store-ops/开放局域网访问.bat
  26. 51 0
      对标试点/01_对标店清单_模板.csv
  27. 143 0
      对标试点/02_采集脚本/collect_douyin.py
  28. 663 0
      对标试点/03_分析/build_dashboard.py
  29. 310 0
      对标试点/03_分析/build_report.py
  30. 258 0
      对标试点/03_分析/detect.py
  31. 148 0
      换机迁移说明.md
  32. BIN
      检测规则说明_20261102.docx
  33. 96 0
      检测规则说明_20261102.md
  34. 218 0
      项目记录/开发日志_2026-10-29.md
  35. 88 0
      项目记录/开发日志_2026-10-31.md
  36. 428 0
      项目记录/开发日志_2026-11-01.md
  37. 147 0
      项目记录/开发日志_2026-11-02.md
  38. 209 0
      项目记录/开发日志_2026-11-03.md
  39. 163 0
      项目记录/规则全景图_可编辑.html
  40. 59 0
      项目记录/规则设计_v2_思维导图.html
  41. 79 0
      项目记录/规则设计_v2_明细.md

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# ---------- 运行时数据(含账号密码哈希、业务数据、原始文件)----------
+store-ops/data/
+*.db
+*.db-shm
+*.db-wal
+
+# ---------- 业务数据 / 生成物 ----------
+对标试点/03_分析/*.csv
+对标试点/03_分析/*.json
+对标试点/04_输出/
+单店分析报告_*.pdf
+
+# ---------- 缓存与临时文件 ----------
+__pycache__/
+*.pyc
+*.pyo
+*.bak
+*.tmp
+.DS_Store
+~$*
+
+# ---------- 日志 ----------
+store-ops/data/sync_log/
+*.log

+ 242 - 0
store-ops/DEPLOY.md

@@ -0,0 +1,242 @@
+# 部署到服务器 · 干净安装
+
+这份说明对应「只拷代码,不带 data 目录」的方式 —— 服务器上是一个全新的空白系统。
+
+---
+
+## 一、服务器要求
+
+| 项 | 要求 |
+|---|---|
+| 系统 | Linux(Ubuntu / CentOS / Debian 都行),macOS 也可以 |
+| Python | **3.7 或更高**,只用标准库,不需要 pip install 任何包 |
+| 内存 | 512MB 够用 |
+| 磁盘 | 200MB(代码 1.3MB + 后续数据增长) |
+
+检查 Python:
+
+```bash
+python3 --version
+```
+
+没有的话:
+
+```bash
+# Ubuntu / Debian
+sudo apt update && sudo apt install -y python3
+# CentOS / RHEL
+sudo yum install -y python3
+```
+
+---
+
+## 二、上传并解压
+
+在**本机**执行(把 IP 和用户名换成你的):
+
+```bash
+scp store-ops-server.zip root@你的服务器IP:/opt/
+```
+
+在**服务器**执行:
+
+```bash
+cd /opt
+unzip store-ops-server.zip
+cd store-ops
+chmod +x start.sh
+```
+
+解压后的目录结构:
+
+```
+store-ops/
+├── backend/          后端(Python,零依赖)
+├── frontend/         前端(含本地 ECharts)
+├── sync/             生意经同步脚本(服务器上通常用不到,见第六节)
+├── tools/            规则文档导出工具
+├── deploy/           systemd 服务模板
+├── start.sh          启动脚本
+├── DEPLOY.md         本说明
+└── README.md         功能说明
+```
+
+---
+
+## 三、试跑一下
+
+```bash
+cd /opt/store-ops
+./start.sh
+```
+
+第一次启动会看到:
+
+```
+ [首次启动] 已创建管理员账号 admin / admin123 —— 登录后请立刻修改密码
+==============================================================
+ 门店检测工作台已启动
+ 本机访问: http://127.0.0.1:8787
+ 局域网:   http://1.2.3.4:8787
+ ...
+```
+
+浏览器打开 `http://服务器IP:8787`:
+
+1. 用 **admin / admin123** 登录
+2. 会强制要求改密码
+3. 进去后是「还没有数据」的引导页
+
+`Ctrl + C` 停止。
+
+---
+
+## 四、让它常驻后台(systemd)
+
+```bash
+# 1. 建一个专用用户(不建议用 root 跑)
+sudo useradd -r -s /sbin/nologin storeops
+
+# 2. 改目录归属
+sudo chown -R storeops:storeops /opt/store-ops
+
+# 3. 装服务
+sudo cp /opt/store-ops/deploy/store-ops.service /etc/systemd/system/
+sudo vim /etc/systemd/system/store-ops.service   # 确认 WorkingDirectory 路径对不对
+
+# 4. 启动
+sudo systemctl daemon-reload
+sudo systemctl enable --now store-ops
+
+# 5. 看状态和日志
+sudo systemctl status store-ops
+sudo journalctl -u store-ops -f
+```
+
+重启服务器后会自动起来。
+
+---
+
+## 五、放行端口
+
+服务器通常有云厂商的**安全组**,光开系统防火墙不够。
+
+```bash
+# 系统防火墙(Ubuntu)
+sudo ufw allow 8787/tcp
+
+# CentOS
+sudo firewall-cmd --permanent --add-port=8787/tcp && sudo firewall-cmd --reload
+```
+
+然后到云控制台 → 安全组 → 入方向 → 添加 8787 端口。
+
+> 如果想用 80 端口,把 `PORT=80` 写进 service 文件,或者前面挂一个 nginx 反代。
+
+### 关于 HTTPS
+
+现在是纯 HTTP,**密码是明文传输的**。如果要走公网,建议:
+
+- 用 **Cloudflare** 免费套一层(域名 + 自动证书),源站指向你的服务器 IP
+- 或者用 nginx + Let's Encrypt(`certbot --nginx`)
+
+真要上公网前告诉我,我帮你配。
+
+---
+
+## 六、关于「每天 01:00 自动同步」
+
+⚠️ **服务器上默认是关闭的。**
+
+自动同步依赖 `ego-browser`(一个能操作「已登录生意经」的浏览器),
+服务器通常没有图形界面,也没有登录态,所以跑不了。
+
+启动时如果你看到:
+
+```
+ 数据同步: 未启用(这台机器上没有 ego-browser)
+           请用【导入数据】上传从生意经导出的 Excel
+```
+
+就是这个原因 —— 这是**正常的**,不是故障。
+
+### 服务器上没有自动同步,怎么办
+
+三种做法,按推荐顺序:
+
+| 做法 | 说明 |
+|---|---|
+| **A. 每天手动导一次**(推荐) | 在浏览器登录生意经 → 导出近30天 xlsx → 在工作台【导入数据】上传。全程 2 分钟 |
+| **B. 本机同步 + 上传** | 在这台 Mac 上保留一份工作台,每天 01:00 自动同步,然后把 `data/inbox/` 里的文件传上去 |
+| **C. 服务器上也装 ego** | 需要图形环境(Xvfb 也行)+ 首次扫码登录,维护成本高,不建议 |
+
+如果后面想让服务器也能自动同步,跟我说,我评估一下用无头浏览器 + cookie 导入的方案。
+
+---
+
+## 七、数据备份
+
+所有数据都在 `data/` 一个目录里:
+
+```
+data/app.db                  账号 + 门店指标 + 批次记录(最重要)
+data/processed/              每次导入的原始文件
+data/inbox/                  待导入的收件箱
+data/factory_thresholds.json 出厂阈值快照
+```
+
+**备份就一句话:**
+
+```bash
+tar czf ~/backup-$(date +%Y%m%d).tar.gz -C /opt/store-ops data
+```
+
+建议加到 crontab,每天凌晨备份一次:
+
+```bash
+0 3 * * * tar czf /root/storeops-$(date +\%Y\%m\%d).tar.gz -C /opt/store-ops data
+```
+
+---
+
+## 八、以后要搬回本机 / 换服务器
+
+把 `data/` 目录整个拷过去覆盖即可,**账号、密码、历史数据、原始文件全部都在**。
+
+---
+
+## 九、常见问题
+
+**打不开网页**
+
+1. `sudo systemctl status store-ops` 看进程在不在
+2. `curl http://127.0.0.1:8787` 在服务器本地试能不能通
+3. 本地能通、外面不通 → 安全组 / 防火墙没放行
+4. 检查是不是绑了 `--local`(只允许本机)
+
+**忘了 admin 密码**
+
+```bash
+cd /opt/store-ops
+sudo -u storeops python3 - <<'EOF'
+import sys; sys.path.insert(0, 'backend')
+import db
+db.set_password(1, 'newpass123', must_change=1)
+print('admin 密码已重置为 newpass123')
+EOF
+sudo systemctl restart store-ops
+```
+
+**端口被占用**
+
+```bash
+sudo lsof -i:8787        # 看谁占着
+PORT=9000 ./start.sh     # 或者换个端口
+```
+
+**想确认数据有没有写进去**
+
+```bash
+cd /opt/store-ops
+python3 -c "import sys; sys.path.insert(0,'backend'); import db; print(db.stats()); print([p['period'] for p in db.get_periods()])"
+```

+ 297 - 0
store-ops/README.md

@@ -0,0 +1,297 @@
+# 门店检测工作台(本地版 · 前后端分离)
+
+怡呵美门店数据检测工作台。每天把门店后台导出的数据丢进来,工作台自动算出
+「哪些门店超违规线 / 低于考核基准 / 零核销」,并给出整改建议与反馈文案。
+
+## 目录结构
+
+```
+store-ops/
+├── backend/            后端(Python 标准库,零依赖)
+│   ├── server.py       HTTP 服务 + API 路由 + 收件箱自动导入
+│   ├── db.py           SQLite 存储与月度聚合
+│   ├── ingest.py       数据导入 CSV / Excel(编码自适应 / 表头别名 / 日·月双粒度)
+│   ├── xlsx.py         .xlsx 解析(标准库 zipfile+XML,无需 openpyxl)
+│   └── rules.py        考核指标默认值 + 检测规则(唯一规则源)
+├── frontend/           前端(原生 HTML/CSS/JS + 本地 ECharts)
+│   ├── index.html
+│   ├── style.css       工作台主样式
+│   ├── report.css      单店分析报告样式(A4 打印 + 移动端)
+│   ├── app.js
+│   └── vendor/echarts.min.js
+├── data/
+│   ├── app.db          SQLite 数据库(自动生成)
+│   ├── inbox/          ★ 收件箱:CSV / Excel 丢进这里,20 秒内自动导入
+│   └── processed/      已处理的文件(按时间戳归档)
+├── 启动工作台.bat       双击启动(Windows)
+├── 启动工作台.command   双击启动(macOS / Linux)
+└── 开放局域网访问.bat   放行防火墙(Windows 管理员)
+```
+
+## 启动
+
+**Windows**:双击 `启动工作台.bat`。
+
+**macOS / Linux**:双击 `启动工作台.command`(首次若被系统拦下,右键 → 打开)。
+或者终端里:
+
+```bash
+./启动工作台.command              # 默认 8787 端口并自动开浏览器
+./启动工作台.command 9000         # 换端口
+./启动工作台.command --local      # 只允许本机访问
+./启动工作台.command --no-open    # 不自动开浏览器
+```
+
+任何系统都可以直接调后端:
+
+```bash
+python3 backend/server.py            # 默认监听 0.0.0.0:8787(本机 + 局域网都能访问)
+python3 backend/server.py --local    # 只允许本机访问(127.0.0.1)
+python3 backend/server.py --port 9000 --open   # 换端口并自动开浏览器
+```
+
+## 同一局域网的其他电脑 / 手机怎么访问
+
+启动窗口会打印两行地址,把「局域网访问」那一行发给同事即可:
+
+```
+本机访问  : http://127.0.0.1:8787
+局域网访问: http://192.168.10.232:8787
+```
+
+**前提**:两台设备连同一个 WiFi / 同一个路由器,并且本机防火墙放行 8787 端口。
+
+- 第一次用:双击 `开放局域网访问.bat`(会弹管理员授权,点「是」),只需做一次,之后一直有效。
+- 仍然打不开时排查:① 本机网络是否被识别为「公用网络」(服务端已按端口放行,不受影响);
+  ② 公司 WiFi 是否开启了「AP 隔离 / 客户端隔离」(开了就无法互访,需找网管);
+  ③ 手机用流量、或连的是另一个网段(如 5G 频段不同路由器)也不行。
+
+⚠️ 该服务没有登录验证,同一内网内任何人打开链接都能看数据和改配置,**只在公司办公网络内使用,不要在公共 WiFi(咖啡馆、酒店)上开放**。想关闭局域网访问就用 `--local` 启动。
+
+## 每天怎么用
+
+1. 从抖音来客后台导出门店数据(CSV 或 Excel .xlsx,日数据带「日期」列最好)
+2. 工作台左侧【导入数据】选文件导入(支持 .csv / .xlsx);**或者**直接把文件丢进 `data/inbox/`,后台自动导入
+3. 打开 http://127.0.0.1:8787 看 KPI、排查门店、点开详情、复制反馈文案给督导
+
+## 数据口径
+
+- 核销率 = 核销券数 ÷ **门店页成交券数**(分母为 0 时不判定)。2026-11-01 起统一用门店页成交券数,不再用意向成交券数
+- 差评率:新增评价 ≥10 用新增口径;否则累计评价 ≥20 用累计口径;
+  两者都不足时仍用累计口径判定,但**最高只判到「预警线」**,不会因一两条差评判成「非常严重」
+- 评分取周期内最近一次有值的数据;**评分为 0 = 未开分**,文案单独显示「未开分」,仍计入评分不达标的 KPI
+- 评价回复率:只在有新增评价时判定(没有新评价就没有回复义务)
+- 「待核销」是**估算值**(门店页成交券数 − 核销券数,未扣退款),只能作邀约优先级参考,不是后台真实未核销订单数
+- 同一文件内门店+日期重复的行只保留首行(其余跳过并提示)
+- 重复导入同门店同日期会覆盖,不会翻倍
+- 同一月份同时有日数据和月汇总时:日数据覆盖 ≥15 天才按日聚合,否则用月汇总
+
+## 账号与权限
+
+打开工作台需要登录。首次启动会自动创建管理员账号:
+
+| 账号 | 初始密码 |
+|---|---|
+| `admin` | `admin123` |
+
+**登录后会强制要求修改密码**,改完才能正常使用。
+
+### 三级角色
+
+| 角色 | 查看 | 导出 | 导入 | 同步 | 改阈值/文案 | 账号管理 |
+|---|---|---|---|---|---|---|
+| 管理员 admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| 运营 editor | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
+| 查看 viewer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
+
+- 左侧栏【账号管理】可以新建账号、改角色、停用、重置密码、删除
+- 权限在前端隐藏入口,**后端接口也会校验**,直接调接口同样会被拒绝(403)
+- 会话有效期 30 天,Cookie 为 HttpOnly;密码用 PBKDF2-SHA256(12 万次迭代)加盐存储
+- 改密码或重置密码后,该账号的已有会话会失效
+
+### 忘记密码怎么办
+
+用另一个管理员账号重置。如果**所有**管理员都进不去:
+
+```bash
+cd store-ops
+python3 - <<'EOF'
+import sys; sys.path.insert(0, 'backend')
+import db, auth
+db.set_password(1, 'newpass123', must_change=1)
+print('admin 密码已重置为 newpass123')
+EOF
+```
+
+## 导出
+
+导出入口统一在左侧栏【导入数据】面板里:
+
+- **下载当前周期最新一份** = 当前周期最近一次导入的原始文件
+- **每条历史记录右侧的【原始文件】** = 那一次导入的原件(同一周期导过多次时用来区分)
+
+导出的都是生意经给的 xlsx 原件,40 列,不含任何计算。
+
+- 原始文件存放在 `data/processed/`,批次表用 `file_path` 记住对应关系
+- 早期从 Windows 迁过来的 CSV 批次没有留存原件,按钮置灰
+- 想按筛选条件导出一张表(含核销率等计算列),可以用 `/api/export.csv`
+
+## 近30天数据自动同步
+
+分析口径是**近30天滚动窗口**(今天往前 29 天到今天),不是自然月。数据每天自动从生意经拉取。
+
+### 自动跑
+- 服务启动后内置调度:**每天 01:00** 自动同步
+- 启动补跑:距上次成功超过 20 小时,服务启动 90 秒后自动补一次
+- 服务窗口会打印「下次自动同步:2026-11-02 01:00」
+
+### 手动跑
+- 工作台左侧 **【同步数据】** 按钮
+- 或 `curl -X POST http://127.0.0.1:8787/api/sync`
+- 查状态:`GET /api/sync/status`
+
+### 它做了什么
+1. 用 ego 浏览器打开生意经(复用登录态)
+2. 调接口建「近30天、全部门店、41 个指标、按统计周期聚合」的导出任务
+3. 轮询约 3.5 分钟拿到签名下载地址
+4. 下载 xlsx 到 `data/inbox/门店数据_<起>_<止>.xlsx`
+5. 收件箱 20 秒内自动导入,文件名里的日期区间决定快照窗口
+
+### 存成什么样
+快照写入 `metrics`,`granularity='snapshot'`,`period` 记「截至日」,
+`window_start` / `window_end` 记统计区间。周期下拉显示「近30天 · 截至 11-01」。
+月汇总数据与快照共存,互不覆盖。
+
+### 依赖与限制
+- **需要 ego 浏览器且生意经已登录**(登录态通常几周过期,过期后需在 ego 里重新登录一次)
+- 同步期间会弹一下浏览器窗口,约 3-5 分钟
+- 生意经建任务**有每日次数上限**(一般在 30 次左右),脚本会先查剩余次数
+- **为什么不用 launchd**:macOS 的 TCC 保护 `~/Documents`,系统级定时任务读不到这个目录里的文件
+  (实测 `head` 都被拒)。所以定时器放在服务进程里,服务从终端启动时是有的权限。
+  `sync/com.yhm.lifesync.plist` 保留作参考,若要启用需在「系统设置 → 隐私与安全性 → 完全磁盘访问」
+  里把 `/bin/bash` 加进去。
+
+## 诊断逻辑(v2)
+
+规则唯一源是 `backend/rules.py`,设计依据见 `项目记录/规则设计_v2_思维导图.html`。
+
+- **4 层并行**:流量 / 转化 / 履约 / 口碑各自独立判定、互不压制(共 17 条规则);
+  只有履约层内部「成交未核销 → 核销率低」按根因去重
+- **5 档**:R 违规红线 / B 考核未达标 / T 运营提升 / N 样本不足
+- **核销链内部去重**:零成交 > 成交未核销 > 核销率低,只报最上游一条
+- **核销金额独立判定**:只要金额 < 基准就报,不受核销率约束(2026-11-01 定)
+- **诊断树选主因**:按层级顺序取第一个命中,只用于生成「一句话结论」,不影响问题清单
+- **问题全列、每条给动作**:命中几个问题就列几个,每个问题对应一条整改动作,不做截断
+- 每家门店输出:一句话结论 + 全部命中问题 + 对应整改动作
+
+## 考核指标(可自定义)
+
+默认值来自飞书《门店考核指标》官方基准线。左侧【指标配置】面板可改,
+改完点【应用到工作台】即可生效。
+
+### 改面板 = 直接改代码
+
+改完不只是存库,还会**同步写回 `backend/rules.py` 的 `DEFAULT_THRESHOLDS`**,
+所以不会出现「界面上是 10、代码里还是 30」这种两套数字打架:
+
+| 场景 | 结果 |
+|---|---|
+| 面板改完保存 | 立即生效 + 写回代码默认值 + 重新加载 |
+| 复制整个文件夹到别的电脑 | 改过的值跟着走 |
+| 【恢复默认】 | 回到**出厂值**(首次运行时快照在 `data/factory_thresholds.json`),并同样写回代码 |
+| 面板右上角标签 | 显示「出厂值」或「已自定义」(对照出厂值判断) |
+
+写回时只替换阈值数值,保留键名、缩进和行尾注释;写入前会生成 `rules.py.bak` 备份,
+写入后清字节码缓存并重新加载模块。
+
+> 注意:因为会改源码,如果这个目录放在 git 里,改阈值会产生一次文件变更。
+
+除了官方基准线,面板里还能调这几条 v2 新增的判定门槛:
+
+| 参数 | 默认 | 作用 |
+|---|---|---|
+| 访问过低线 | 100 次 | 低于此值判「访问过低」 |
+| 成交转化率过低线 | 10% | 成交人数 ÷ 访问 低于此值 → 「转化率低」 |
+| 视频数下限 | 10 条 | 新发布视频少于此数 → 「视频量少」 |
+| 点亮数下限 | 10 次 | 门店点亮少于此数 → 「点亮偏低」 |
+| 新增评价数下限 | 10 条 | 新增评价少于此数 → 「新增评价少」 |
+| 未核销积压提醒线 | 30 张 | 估算未核销券达到此数 → 提示集中邀约 |
+| 核销率最小成交券数 | 5 张 | 成交券太少时核销率不稳定,不判定 |
+| 客单价最小核销券数 | 5 张 | 核销券太少时不算客单价 |
+| 差评率最小样本(新增/累计) | 10 / 20 条 | 样本不足最高只判预警线 |
+| 回复率最小新增评价数 | 1 条 | 没有新评价就不判回复率 |
+
+## API 一览(给后续自动化用)
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | /api/meta | 周期列表、阈值、导入历史、统计 |
+| GET | /api/dashboard?period=2026-10 | 工作台全量数据 |
+| PUT | /api/thresholds | 保存自定义阈值 |
+| POST | /api/thresholds/reset | 恢复官方默认 |
+| POST | /api/import?filename=&period=&date= | 导入 CSV / Excel(body=文件字节) |
+| GET | /api/export.file?period= | **导出原始文件**(该周期最近一次导入的 xlsx/csv 原件) |
+| GET | /api/batch/\<id\>/file | 导出指定批次的原始文件 |
+| GET | /api/export.csv?period=&kpi=&city=&cat=&q= | 筛选结果导出为 CSV(含核销率/待核销等计算列) |
+| GET | /api/history | 导入批次 |
+
+## 深链接
+
+- `/#TH` 直达考核指标面板
+- `/#IM` 直达导入面板
+- `/#<门店ID>` 直达该店详情抽屉
+
+## 单店分析报告
+
+点开任一门店的详情抽屉,底部两个按钮:
+
+- **【预览分析结果】** —— 按文案模板生成的反馈短文,可改完直接复制发给督导
+- **【导出报告】** —— 生成一页 A4 的门店检测分析报告
+
+报告包含五段:关键指标(带基准/目标刻度的指标条)→ 风险与限流因素(问题 + 建议动作)→
+经营数据(客资 / 核销 / 退单 / 待核销 / 访问 / 成交 / 流失)→ 分析洞察 → 门店调整动作。
+
+报告顶部有两个操作:
+
+| 按钮 | 得到什么 |
+|---|---|
+| 打印 / 另存为 PDF | 走系统打印对话框,目标选「另存为 PDF」即得 PDF 文件(macOS 选「存储为 PDF」) |
+| 下载 HTML | 下载一个**自包含**的单文件 HTML(样式内联,不依赖服务),可直接发微信或存档 |
+
+- 报告内容全部来自当前【文案配置】的模板,改了文案报告跟着变,不用改代码
+- 手机宽度(375px)下报告自动改为单列排版,督导可以直接在手机上看
+- 报告里不出现「同城竞对」数据——那部分属于「对标试点」轨道 B,目前未接入本工作台
+
+## 后续可做
+
+- 自动下载:接抖音来客/服务商 API 定时拉数,替代手动导出
+- 处理闭环:异常门店的整改动作记录与回填
+- 权限分层:区域经理/督导只看自己片区
+- 报告批量导出:按城市/督导一次导出多店 PDF
+
+## 文案配置
+
+左侧菜单【文案配置】可自定义门店分析反馈文案:反馈文案结构、问题文案、整改建议、严重档位名称。大括号变量生成时自动替换({门店名} {周期} {访问} {金额} {评分} {v} {base} {口径} {档} 等),未知变量原样保留。修改存库、立即对全部门店生效,【恢复默认】可一键还原。
+
+每个字段右侧有「+ 变量」按钮,点开可点选变量直接插入,避免手写变量名出错。写错的变量会标红并在预览区顶部提示,不会静默原样输出。
+
+### 反馈文案可用变量
+
+| 短名 | 含义 | 等价的数据表列名(别名,可直接写) |
+|---|---|---|
+| {客资} | 门店意向成交券数 | {门店意向成交券数} |
+| {核销} | 核销券数 | |
+| {核销券数} | 核销券数 | {门店核销券数} |
+| {成交券数} | 意向成交券数 | |
+| {退单} | 门店意向退款券数 | {门店意向退款券数} {退款券数} |
+| {访问} | 门店页访问次数 | {门店页访问次数} {门店页有效人数} {访问次数} |
+| {成交} | 实际成交人数 | {门店实际成交人数} {成交人数} |
+| {流失} | 访问-成交 | {流失人数} |
+| {成交占比} {流失占比} | 占访问比例 | {流失人数占比} |
+| {待核销} | 成交券-核销券 | {待核销订单数} {后台待核销订单} |
+| {金额} | 核销金额(元) | {核销金额} |
+| {评分} | 团购评分 | {门店评分} {团购评分} |
+| {评分基准} {核销率} {核销目标} {回复率} {回复基准} {核销金额基准} | 指标与基准值 | {评价回复率} {核销率基准} |
+| {门店名} {门店ID} {城市} {周期} | 门店与周期 | {门店名称} {门店城市} {统计周期} |
+
+问题文案另可用:`{v}` 实际值、`{base}` 基准值、`{口径}` 新增/累计、`{档}` 档位名;条目格式可用 `{i}` 序号、`{问题}`/`{建议}`/`{洞察}` 内容。

+ 79 - 0
store-ops/backend/auth.py

@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+"""账号与权限:密码哈希、会话、角色权限表
+
+只依赖标准库。密码用 PBKDF2-HMAC-SHA256,每个账号独立盐值。
+"""
+import hashlib
+import hmac
+import secrets
+
+ITERATIONS = 120_000
+
+# ---------------------------------------------------------------- 角色定义
+# 三级角色,权限逐级递增
+ROLES = {
+    "viewer": {"label": "查看", "desc": "只看数据,不能改任何东西", "level": 1},
+    "editor": {"label": "运营", "desc": "可导入、同步、导出,不能改判定规则", "level": 2},
+    "admin":  {"label": "管理员", "desc": "全部权限,含账号管理", "level": 3},
+}
+ROLE_ORDER = ["admin", "editor", "viewer"]
+
+# 权限点
+PERMS = {
+    "view":   "查看数据",
+    "export": "导出原始文件",
+    "import": "导入数据",
+    "sync":   "同步生意经数据",
+    "config": "修改指标阈值 / 文案",
+    "users":  "账号管理",
+}
+
+# 角色 → 拥有的权限
+GRANTS = {
+    "viewer": {"view"},
+    "editor": {"view", "export", "import", "sync"},
+    "admin":  {"view", "export", "import", "sync", "config", "users"},
+}
+
+
+def can(role, perm):
+    return perm in GRANTS.get(role or "", set())
+
+
+def role_label(role):
+    return ROLES.get(role or "", {}).get("label", role or "-")
+
+
+# ---------------------------------------------------------------- 密码
+def hash_password(password, salt=None):
+    """返回 (hash_hex, salt_hex)"""
+    salt = salt or secrets.token_hex(16)
+    digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"),
+                                 salt.encode("utf-8"), ITERATIONS)
+    return digest.hex(), salt
+
+
+def verify_password(password, stored_hash, salt):
+    if not stored_hash or not salt:
+        return False
+    digest, _ = hash_password(password, salt)
+    return hmac.compare_digest(digest, stored_hash)
+
+
+def check_strength(password):
+    """返回 (是否合格, 提示)"""
+    if not password or len(password) < 6:
+        return False, "密码至少 6 位"
+    if password.isdigit():
+        return False, "密码不能全是数字"
+    return True, ""
+
+
+# ---------------------------------------------------------------- 会话
+def new_token():
+    return secrets.token_urlsafe(32)
+
+
+def hash_token(token):
+    """会话令牌在库里只存哈希,泄露数据库也拿不到可用的 cookie"""
+    return hashlib.sha256(token.encode("utf-8")).hexdigest()

+ 130 - 0
store-ops/backend/codesync.py

@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+"""
+阈值「写回代码」
+
+需求:在【指标配置】面板改数字,就是改规则本身 —— 不再有「代码默认值」和「库里自定义值」
+两套数字打架。
+
+做法:
+  1. 面板保存 → 先写库(立即生效)
+  2. 再把新值写回 rules.py 的 DEFAULT_THRESHOLDS(代码里的「标准」跟着变)
+  3. 重新加载 rules 模块
+
+出厂值单独留一份快照(data/factory_thresholds.json),
+【恢复默认】= 回到出厂值,并且同样写回代码,保证任何时刻库和代码都一致。
+"""
+import importlib
+import importlib.util
+import json
+import os
+import re
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+RULES_PY = os.path.join(BASE, "rules.py")
+DATA_DIR = os.path.join(os.path.dirname(BASE), "data")
+FACTORY_FILE = os.path.join(DATA_DIR, "factory_thresholds.json")
+
+
+def _fmt(v):
+    """把值格式化成 Python 源码里的写法"""
+    if isinstance(v, (list, tuple)):
+        return "[" + ", ".join(_fmt(x) for x in v) + "]"
+    if isinstance(v, bool):
+        return "True" if v else "False"
+    if isinstance(v, float):
+        if abs(v - round(v)) < 1e-12:
+            return str(int(round(v)))
+        return "%.10g" % v          # 去掉浮点噪音:0.0028000000000000004 → 0.0028
+    if isinstance(v, int):
+        return str(v)
+    return repr(v)
+
+
+def _value_re(v):
+    """匹配某个值在源码里的形式(数字/列表),用于定位替换"""
+    if isinstance(v, (list, tuple)):
+        return r"\[[^\[\]]*\]"
+    return r"[^,\]\}\n#]+"
+
+
+def write_defaults(T):
+    """
+    把 T 写回 rules.py 的 DEFAULT_THRESHOLDS。
+    只改已有键的「值」,保留键名、缩进、行尾注释和其它代码。
+    返回 (ok, msg)。
+    """
+    try:
+        with open(RULES_PY, encoding="utf-8") as f:
+            src = f.read()
+    except OSError as e:
+        return False, "读不到 rules.py:%s" % e
+
+    start = src.find("DEFAULT_THRESHOLDS = {")
+    if start < 0:
+        return False, "rules.py 里找不到 DEFAULT_THRESHOLDS"
+    end = src.find("\n}", start)
+    if end < 0:
+        return False, "DEFAULT_THRESHOLDS 结构异常"
+    head, block, tail = src[:start], src[start:end], src[end:]
+
+    changed = 0
+    for k, v in T.items():
+        # 组1=键与冒号  组2=旧值  组3=逗号 + 行尾注释 + 换行
+        pat = re.compile(
+            r'("' + re.escape(k) + r'"\s*:\s*)(' + _value_re(v) + r')(\s*,[^\n]*\n)')
+        m = pat.search(block)
+        if not m:
+            continue
+        if m.group(2).strip() == _fmt(v):
+            continue
+        block = block[:m.start()] + m.group(1) + _fmt(v) + m.group(3) + block[m.end():]
+        changed += 1
+
+    if not changed:
+        return True, "代码默认值已是最新,无需变更"
+
+    try:
+        with open(RULES_PY + ".bak", "w", encoding="utf-8") as f:
+            f.write(src)
+        tmp = RULES_PY + ".tmp"
+        with open(tmp, "w", encoding="utf-8") as f:
+            f.write(head + block + tail)
+        os.replace(tmp, RULES_PY)
+    except OSError as e:
+        return False, "写 rules.py 失败:%s" % e
+
+    # 关键:macOS 自带 Python 把 .pyc 放在 ~/Library/Caches/com.apple.python,
+    # 不在这里清掉的话,同尺寸同秒的改动会被旧字节码顶掉(踩过)
+    try:
+        cache = importlib.util.cache_from_source(RULES_PY)
+        if os.path.exists(cache):
+            os.remove(cache)
+    except Exception:
+        pass
+
+    try:
+        import rules
+        importlib.reload(rules)
+    except Exception as e:
+        return False, "已写入但重新加载失败:%s(重启服务后生效)" % e
+
+    return True, "已同步写回代码(%d 项)" % changed
+
+
+def get_factory():
+    """出厂阈值:首次运行时从代码快照一份,之后固定不变"""
+    os.makedirs(DATA_DIR, exist_ok=True)
+    if os.path.isfile(FACTORY_FILE):
+        try:
+            with open(FACTORY_FILE, encoding="utf-8") as f:
+                return json.load(f)
+        except Exception:
+            pass
+    import rules
+    snap = dict(rules.DEFAULT_THRESHOLDS)
+    try:
+        with open(FACTORY_FILE, "w", encoding="utf-8") as f:
+            json.dump(snap, f, ensure_ascii=False, indent=1)
+    except OSError:
+        pass
+    return snap

+ 645 - 0
store-ops/backend/db.py

@@ -0,0 +1,645 @@
+# -*- coding: utf-8 -*-
+"""
+SQLite 存储层
+- stores          门店主数据
+- metrics         指标明细(按 门店 × 周期 × 日期 × 粒度 唯一)
+- batches         导入批次
+- thresholds      考核指标阈值(单行 JSON)
+- settings        杂项键值
+
+日粒度数据按月聚合,聚合口径统一在这里,前端不重复算。
+"""
+import json
+import os
+import re
+import sqlite3
+from datetime import datetime
+
+import rules
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+DATA_DIR = os.path.join(os.path.dirname(BASE), "data")
+DB_PATH = os.path.join(DATA_DIR, "app.db")
+INBOX = os.path.join(DATA_DIR, "inbox")
+PROCESSED = os.path.join(DATA_DIR, "processed")
+
+SCHEMA = """
+CREATE TABLE IF NOT EXISTS stores (
+  id          TEXT PRIMARY KEY,
+  name        TEXT NOT NULL,
+  city        TEXT,
+  status      TEXT DEFAULT 'active',
+  first_seen  TEXT,
+  last_seen   TEXT
+);
+CREATE TABLE IF NOT EXISTS metrics (
+  store_id      TEXT NOT NULL,
+  period        TEXT NOT NULL,          -- YYYY-MM(月)或 截至日 YYYY-MM-DD(快照)
+  date          TEXT NOT NULL DEFAULT '',-- YYYY-MM-DD;月汇总行留空
+  granularity   TEXT NOT NULL,          -- daily | period | snapshot
+  window_start  TEXT DEFAULT '',        -- 快照:统计窗口开始日
+  window_end    TEXT DEFAULT '',        -- 快照:统计窗口结束日(= 数据截至日)
+  visit         INTEGER DEFAULT 0,
+  deal          INTEGER DEFAULT 0,
+  deal_refund   INTEGER DEFAULT 0,
+  intend_deal   INTEGER DEFAULT 0,
+  used_amount   REAL    DEFAULT 0,
+  used_coupons  INTEGER DEFAULT 0,
+  intend_refund INTEGER DEFAULT 0,
+  videos        INTEGER DEFAULT 0,
+  video_views   INTEGER DEFAULT 0,
+  score         REAL,
+  reviews_new   INTEGER DEFAULT 0,
+  good_new      INTEGER DEFAULT 0,
+  bad_new       INTEGER DEFAULT 0,
+  reviews_total INTEGER DEFAULT 0,
+  good_total    INTEGER DEFAULT 0,
+  bad_total     INTEGER DEFAULT 0,
+  reply_rate    REAL,
+  complaint_rate REAL,
+  return_rate   REAL,
+  lit           INTEGER DEFAULT 0,
+  lit_posts     INTEGER DEFAULT 0,
+  favs          INTEGER DEFAULT 0,
+  rank_note     TEXT,
+  batch_id      INTEGER,
+  PRIMARY KEY (store_id, period, date, granularity)
+);
+CREATE INDEX IF NOT EXISTS idx_metrics_period ON metrics(period, granularity);
+CREATE TABLE IF NOT EXISTS batches (
+  id          INTEGER PRIMARY KEY AUTOINCREMENT,
+  filename    TEXT,
+  rows_read   INTEGER,
+  rows_written INTEGER,
+  new_stores  INTEGER,
+  period      TEXT,
+  date_from   TEXT,
+  date_to     TEXT,
+  created_at  TEXT,
+  note        TEXT,
+  file_path   TEXT          -- 原始文件在 data/processed 下的文件名
+);
+
+CREATE TABLE IF NOT EXISTS users (
+  id            INTEGER PRIMARY KEY AUTOINCREMENT,
+  username      TEXT UNIQUE NOT NULL,
+  pass_hash     TEXT NOT NULL,
+  salt          TEXT NOT NULL,
+  role          TEXT NOT NULL DEFAULT 'viewer',
+  display_name  TEXT DEFAULT '',
+  active        INTEGER DEFAULT 1,
+  must_change   INTEGER DEFAULT 0,
+  created_at    TEXT,
+  last_login    TEXT
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+  token_hash  TEXT PRIMARY KEY,
+  user_id     INTEGER NOT NULL,
+  created_at  TEXT,
+  expires_at  TEXT,
+  ip          TEXT DEFAULT ''
+);
+CREATE TABLE IF NOT EXISTS thresholds (
+  id         INTEGER PRIMARY KEY CHECK (id = 1),
+  data       TEXT NOT NULL,
+  updated_at TEXT
+);
+CREATE TABLE IF NOT EXISTS settings (
+  key   TEXT PRIMARY KEY,
+  value TEXT
+);
+"""
+
+METRIC_FIELDS = ["visit", "deal", "deal_refund", "intend_deal", "used_amount",
+                 "used_coupons", "intend_refund", "videos", "video_views", "score",
+                 "reviews_new", "good_new", "bad_new", "reviews_total", "good_total",
+                 "bad_total", "reply_rate", "complaint_rate", "return_rate",
+                 "lit", "lit_posts", "favs", "rank_note"]
+
+
+def connect():
+    os.makedirs(DATA_DIR, exist_ok=True)
+    os.makedirs(INBOX, exist_ok=True)
+    os.makedirs(PROCESSED, exist_ok=True)
+    conn = sqlite3.connect(DB_PATH, timeout=15)
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA journal_mode=WAL")
+    return conn
+
+
+def _migrate(conn):
+    """老库补齐新增列(SQLite 的 ADD COLUMN 不会报错重复)"""
+    cols = {r[1] for r in conn.execute("PRAGMA table_info(metrics)")}
+    for c in ("window_start", "window_end"):
+        if c not in cols:
+            conn.execute("ALTER TABLE metrics ADD COLUMN %s TEXT DEFAULT ''" % c)
+    bcols = {r[1] for r in conn.execute("PRAGMA table_info(batches)")}
+    if "file_path" not in bcols:
+        conn.execute("ALTER TABLE batches ADD COLUMN file_path TEXT")
+
+
+def backfill_batch_files(conn):
+    """老批次补上「原始文件」路径:用 created_at 的时间戳去匹配 processed 里的文件名前缀"""
+    try:
+        rows = conn.execute("SELECT id, created_at FROM batches "
+                            "WHERE file_path IS NULL OR file_path=''").fetchall()
+        if not rows or not os.path.isdir(PROCESSED):
+            return
+        idx = {}
+        for f in os.listdir(PROCESSED):
+            m = re.match(r"^(\d{8}_\d{6})_", f)
+            if m:
+                idx[m.group(1)] = f
+        for r in rows:
+            try:
+                stamp = datetime.fromisoformat(r["created_at"]).strftime("%Y%m%d_%H%M%S")
+            except (TypeError, ValueError):
+                continue
+            f = idx.get(stamp)
+            if f:
+                conn.execute("UPDATE batches SET file_path=? WHERE id=?", (f, r["id"]))
+    except Exception:
+        pass
+
+
+def init_db():
+    conn = connect()
+    with conn:
+        conn.executescript(SCHEMA)
+        _migrate(conn)
+        backfill_batch_files(conn)
+        cur = conn.execute("SELECT id FROM thresholds WHERE id=1")
+        if not cur.fetchone():
+            conn.execute("INSERT INTO thresholds(id, data, updated_at) VALUES (1, ?, ?)",
+                         (json.dumps(rules.DEFAULT_THRESHOLDS, ensure_ascii=False),
+                          datetime.now().isoformat(timespec="seconds")))
+    conn.close()
+
+
+def get_thresholds():
+    """读取阈值;库里没有的新键(v2 新增门槛)用默认值补全,老库不用迁移"""
+    conn = connect()
+    row = conn.execute("SELECT data FROM thresholds WHERE id=1").fetchone()
+    conn.close()
+    T = dict(rules.DEFAULT_THRESHOLDS)
+    if row:
+        try:
+            T.update(json.loads(row["data"]))
+        except Exception:
+            pass
+    return T
+
+
+def set_thresholds(T):
+    conn = connect()
+    with conn:
+        conn.execute("UPDATE thresholds SET data=?, updated_at=? WHERE id=1",
+                     (json.dumps(T, ensure_ascii=False),
+                      datetime.now().isoformat(timespec="seconds")))
+    conn.close()
+
+
+def reset_thresholds():
+    set_thresholds(dict(rules.DEFAULT_THRESHOLDS))
+    return dict(rules.DEFAULT_THRESHOLDS)
+
+
+def get_setting(key, default=None):
+    conn = connect()
+    row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
+    conn.close()
+    return row["value"] if row else default
+
+
+def set_setting(key, value):
+    conn = connect()
+    with conn:
+        conn.execute("INSERT INTO settings(key, value) VALUES(?,?) "
+                     "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, str(value)))
+    conn.close()
+
+
+# ---- 文案模板(settings 表单行 JSON,key='texts')----
+def get_texts():
+    """
+    读取文案。库里存的是整份模板,升级后旧存档会盖住新默认值,所以按规则迁移:
+      存档值 == v1 默认值  → 用户没改过,用新默认值
+      存档值 != v1 默认值  → 用户自定义过,保留
+    """
+    conn = connect()
+    row = conn.execute("SELECT value FROM settings WHERE key='texts'").fetchone()
+    conn.close()
+    merged = dict(rules.DEFAULT_TEXTS)
+    if row:
+        try:
+            stored = json.loads(row["value"])
+        except Exception:
+            stored = {}
+        for k, v in stored.items():
+            if k not in rules.DEFAULT_TEXTS:
+                continue                      # 已废弃的旧键直接丢弃
+            if rules.V1_TEXTS.get(k) == v:
+                continue                      # 与 v1 默认一致 = 没自定义过
+            merged[k] = v
+    return merged
+
+
+def set_texts(X):
+    """只存「与默认值不同」的键,默认值以后再改也能自动生效"""
+    diff = {k: v for k, v in X.items()
+            if k in rules.DEFAULT_TEXTS and v != rules.DEFAULT_TEXTS[k]}
+    conn = connect()
+    with conn:
+        conn.execute(
+            "INSERT INTO settings(key, value) VALUES('texts', ?) "
+            "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
+            (json.dumps(diff, ensure_ascii=False),))
+    conn.close()
+
+
+def reset_texts():
+    set_texts(dict(rules.DEFAULT_TEXTS))
+    return dict(rules.DEFAULT_TEXTS)
+
+
+def _pick_granularity(conn, period):
+    """
+    快照(近30天滚动窗口)优先级最高:一个 period 只有一份快照行/店。
+    同一周期两种粒度都存在时的取舍:
+    日数据覆盖 >= 15 天才用日粒度(避免刚导 1 天日报就把整月视图切成残缺的日聚合);
+    否则用月汇总行。
+    """
+    has_snap = conn.execute("SELECT 1 FROM metrics WHERE period=? AND granularity='snapshot' LIMIT 1",
+                            (period,)).fetchone()
+    if has_snap:
+        return "snapshot"
+    has_daily = conn.execute("SELECT 1 FROM metrics WHERE period=? AND granularity='daily' LIMIT 1",
+                             (period,)).fetchone()
+    has_month = conn.execute("SELECT 1 FROM metrics WHERE period=? AND granularity='period' LIMIT 1",
+                             (period,)).fetchone()
+    if has_daily and has_month:
+        n = conn.execute("SELECT COUNT(DISTINCT date) c FROM metrics WHERE period=? "
+                         "AND granularity='daily' AND date<>''", (period,)).fetchone()["c"]
+        return "daily" if n >= 15 else "period"
+    return "daily" if has_daily else "period"
+
+
+def get_periods():
+    """
+    返回周期列表。三种粒度:
+      快照 snapshot —— 近 N 天滚动窗口,period 记「截至日」,带 window_start/end
+      daily         —— 当月按日导入
+      period        —— 月汇总
+    """
+    conn = connect()
+    rows = conn.execute("""
+        SELECT period, granularity, COUNT(DISTINCT store_id) AS stores, COUNT(DISTINCT date) AS days
+        FROM metrics GROUP BY period, granularity
+    """).fetchall()
+    plans = {}
+    for r in rows:
+        plans.setdefault(r["period"], {})[r["granularity"]] = r
+    out = []
+    for p, gs in plans.items():
+        g = _pick_granularity(conn, p)
+        r = gs.get(g) or next(iter(gs.values()))
+        item = {"period": p, "stores": r["stores"], "granularity": g,
+                "days": r["days"] if g == "daily" else 0}
+        if g == "snapshot":
+            w = conn.execute("SELECT window_start, window_end FROM metrics "
+                             "WHERE period=? AND granularity='snapshot' LIMIT 1", (p,)).fetchone()
+            ws, we = (w["window_start"] or "", w["window_end"] or "") if w else ("", "")
+            d = _days_between(ws, we)
+            item.update({"window_start": ws, "window_end": we, "as_of": we or p,
+                         "days": d or 30,
+                         "label": "近%d天 · 截至 %s" % (d or 30, (we or p)[5:])})
+        item["sort_key"] = (item.get("as_of") or (p + "-01"))
+        out.append(item)
+    conn.close()
+    return sorted(out, key=lambda x: x["sort_key"], reverse=True)
+
+
+def latest_period():
+    ps = get_periods()
+    return ps[0]["period"] if ps else None
+
+
+def _days_between(a, b):
+    """两个 YYYY-MM-DD 之间的天数(含首尾)"""
+    try:
+        from datetime import date
+        d1 = date.fromisoformat(a)
+        d2 = date.fromisoformat(b)
+        return (d2 - d1).days + 1
+    except Exception:
+        return 0
+
+
+def _weighted(items, weight_key, value_key):
+    """加权平均:权重为 None/0 时退化为简单平均"""
+    num = den = 0.0
+    simple = []
+    for it in items:
+        v = it.get(value_key)
+        if v is None:
+            continue
+        w = it.get(weight_key) or 0
+        num += v * w
+        den += w
+        simple.append(v)
+    if den > 0:
+        return num / den
+    return (sum(simple) / len(simple)) if simple else None
+
+
+def aggregate_period(period):
+    """
+    按月聚合。日粒度优先;没有日粒度就用月汇总行。
+    返回每店一条,含派生指标 hx_rate / cp_rate / cp_src。
+    """
+    conn = connect()
+    g = _pick_granularity(conn, period)
+    rows = conn.execute("""
+        SELECT m.*, s.name, s.city FROM metrics m JOIN stores s ON s.id = m.store_id
+        WHERE m.period=? AND m.granularity=?
+        ORDER BY m.store_id, m.date
+    """, (period, g)).fetchall()
+    conn.close()
+
+    grouped = {}
+    for r in rows:
+        grouped.setdefault(r["store_id"], []).append(dict(r))
+
+    out = []
+    for sid, items in grouped.items():
+        latest = max(items, key=lambda x: (x["date"] or ""))
+        total = {k: 0 for k in ("visit", "intend_deal", "used_coupons", "used_amount",
+                                "deal", "deal_refund", "videos", "video_views", "reviews_new",
+                                "bad_new", "reviews_total", "bad_total", "favs", "lit")}
+        for it in items:
+            for k in total:
+                total[k] += (it.get(k) or 0)
+
+        score = None
+        rank_note = ""
+        for it in sorted(items, key=lambda x: x["date"] or ""):
+            if it.get("score") is not None:
+                score = it["score"]          # 取最近一次有值(含 0 分未开分)的评分
+            v = (it.get("rank_note") or "").strip()
+            if v and v not in ("-", "无"):
+                rank_note = v                # 取最近一次有内容的上榜信息
+
+        # 核销率分母 = 门店页成交券数(2026-11-01 用户确定口径)
+        hx_rate = rules.diff_rate(total["used_coupons"], total["deal"])
+        cp_rate, cp_src, _cp_small = rules.bad_rate(
+            total["reviews_new"], total["bad_new"], total["reviews_total"], total["bad_total"])
+        out.append({
+            "id": sid,
+            "name": latest["name"],
+            "city": latest["city"] or "",
+            "visit": total["visit"],
+            "deal": total["deal"],
+            "deal_refund": total["deal_refund"],
+            "intend_deal": total["intend_deal"],
+            "used_coupons": total["used_coupons"],
+            "used_amount": round(total["used_amount"], 2),
+            "score": score,
+            "hx_rate": hx_rate,
+            "cp_rate": cp_rate,
+            "cp_src": cp_src,
+            # 评价明细:差评率口径与样本量判定需要原始计数
+            "reviews_new": total["reviews_new"],
+            "bad_new": total["bad_new"],
+            "reviews_total": total["reviews_total"],
+            "bad_total": total["bad_total"],
+            "lit": total["lit"],
+            "rank_note": rank_note,
+            "return_rate": _weighted(items, "deal", "return_rate"),
+            "complaint_rate": _weighted(items, "deal", "complaint_rate"),
+            "reply_rate": _weighted(items, "reviews_new", "reply_rate"),
+            "videos": total["videos"],
+            "video_views": total["video_views"],
+            "favs": total["favs"],
+            "days": (_days_between(items[0].get("window_start") or "",
+                                   items[0].get("window_end") or "")
+                     if g == "snapshot"
+                     else len({it["date"] for it in items if it["date"]})),
+            "window_start": items[0].get("window_start") or "",
+            "window_end": items[0].get("window_end") or "",
+        })
+    return out
+
+
+def upsert_store(conn, sid, name, city, today):
+    cur = conn.execute("SELECT id FROM stores WHERE id=?", (sid,))
+    if cur.fetchone():
+        conn.execute("UPDATE stores SET name=?, city=?, last_seen=? WHERE id=?",
+                     (name, city, today, sid))
+        return False
+    conn.execute("INSERT INTO stores(id, name, city, first_seen, last_seen) VALUES (?,?,?,?,?)",
+                 (sid, name, city, today, today))
+    return True
+
+
+def insert_metric(conn, row, batch_id):
+    cols = ", ".join(METRIC_FIELDS)
+    qs = ", ".join(["?"] * len(METRIC_FIELDS))
+    vals = [row.get(k) for k in METRIC_FIELDS]
+    conn.execute(
+        "INSERT OR REPLACE INTO metrics(store_id, period, date, granularity, window_start, window_end, "
+        "%s, batch_id) VALUES (?,?,?,?,?,?,%s,?)" % (cols, qs),
+        [row["store_id"], row["period"], row.get("date", ""), row["granularity"],
+         row.get("window_start", ""), row.get("window_end", "")] + vals + [batch_id])
+
+
+def record_batch(conn, info):
+    cur = conn.execute(
+        "INSERT INTO batches(filename, rows_read, rows_written, new_stores, period, date_from, date_to, "
+        "created_at, note, file_path) VALUES (?,?,?,?,?,?,?,?,?,?)",
+        (info.get("filename"), info.get("rows_read"), info.get("rows_written"),
+         info.get("new_stores"), info.get("period"), info.get("date_from"),
+         info.get("date_to"), datetime.now().isoformat(timespec="seconds"), info.get("note"),
+         info.get("file_path")))
+    return cur.lastrowid
+
+
+# ---------------------------------------------------------------- 账号
+def user_count():
+    conn = connect()
+    n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
+    conn.close()
+    return n
+
+
+def get_user_by_name(username):
+    conn = connect()
+    r = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
+    conn.close()
+    return dict(r) if r else None
+
+
+def get_user(uid):
+    conn = connect()
+    r = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
+    conn.close()
+    return dict(r) if r else None
+
+
+def list_users():
+    conn = connect()
+    rows = [dict(r) for r in conn.execute(
+        "SELECT id, username, role, display_name, active, must_change, created_at, last_login "
+        "FROM users ORDER BY CASE role WHEN 'admin' THEN 0 WHEN 'editor' THEN 1 ELSE 2 END, id")]
+    conn.close()
+    return rows
+
+
+def create_user(username, password, role, display_name=""):
+    import auth
+    h, salt = auth.hash_password(password)
+    conn = connect()
+    try:
+        cur = conn.execute(
+            "INSERT INTO users(username, pass_hash, salt, role, display_name, active, must_change, created_at) "
+            "VALUES (?,?,?,?,?,1,0,?)",
+            (username, h, salt, role, display_name or username,
+             datetime.now().isoformat(timespec="seconds")))
+        conn.commit()
+        return cur.lastrowid
+    finally:
+        conn.close()
+
+
+def update_user(uid, role=None, display_name=None, active=None):
+    sets, vals = [], []
+    if role is not None:
+        sets.append("role=?"); vals.append(role)
+    if display_name is not None:
+        sets.append("display_name=?"); vals.append(display_name)
+    if active is not None:
+        sets.append("active=?"); vals.append(1 if active else 0)
+    if not sets:
+        return
+    vals.append(uid)
+    conn = connect()
+    conn.execute("UPDATE users SET %s WHERE id=?" % ",".join(sets), vals)
+    conn.commit()
+    conn.close()
+
+
+def set_password(uid, password, must_change=0):
+    import auth
+    h, salt = auth.hash_password(password)
+    conn = connect()
+    conn.execute("UPDATE users SET pass_hash=?, salt=?, must_change=? WHERE id=?",
+                 (h, salt, 1 if must_change else 0, uid))
+    conn.commit()
+    conn.close()
+
+
+def delete_user(uid):
+    conn = connect()
+    conn.execute("DELETE FROM sessions WHERE user_id=?", (uid,))
+    conn.execute("DELETE FROM users WHERE id=?", (uid,))
+    conn.commit()
+    conn.close()
+
+
+def admin_count():
+    conn = connect()
+    n = conn.execute("SELECT COUNT(*) FROM users WHERE role='admin' AND active=1").fetchone()[0]
+    conn.close()
+    return n
+
+
+def touch_login(uid):
+    conn = connect()
+    conn.execute("UPDATE users SET last_login=? WHERE id=?",
+                 (datetime.now().isoformat(timespec="seconds"), uid))
+    conn.commit()
+    conn.close()
+
+
+# ---------------------------------------------------------------- 会话
+def create_session(token_hash, user_id, expires_at, ip=""):
+    conn = connect()
+    conn.execute("DELETE FROM sessions WHERE expires_at < ?",
+                 (datetime.now().isoformat(timespec="seconds"),))
+    conn.execute("INSERT INTO sessions(token_hash, user_id, created_at, expires_at, ip) VALUES (?,?,?,?,?)",
+                 (token_hash, user_id, datetime.now().isoformat(timespec="seconds"), expires_at, ip))
+    conn.commit()
+    conn.close()
+
+
+def get_session(token_hash):
+    """返回 (user_dict, expires_at) 或 (None, None)"""
+    conn = connect()
+    r = conn.execute("SELECT * FROM sessions WHERE token_hash=?", (token_hash,)).fetchone()
+    if not r:
+        conn.close()
+        return None, None
+    if (r["expires_at"] or "") < datetime.now().isoformat(timespec="seconds"):
+        conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,))
+        conn.commit()
+        conn.close()
+        return None, None
+    u = conn.execute("SELECT * FROM users WHERE id=?", (r["user_id"],)).fetchone()
+    conn.close()
+    if not u or not u["active"]:
+        return None, None
+    return dict(u), r["expires_at"]
+
+
+def delete_session(token_hash):
+    conn = connect()
+    conn.execute("DELETE FROM sessions WHERE token_hash=?", (token_hash,))
+    conn.commit()
+    conn.close()
+
+
+def delete_user_sessions(uid):
+    conn = connect()
+    conn.execute("DELETE FROM sessions WHERE user_id=?", (uid,))
+    conn.commit()
+    conn.close()
+
+
+def get_batch(batch_id):
+    conn = connect()
+    r = conn.execute("SELECT * FROM batches WHERE id=?", (batch_id,)).fetchone()
+    conn.close()
+    return dict(r) if r else None
+
+
+def latest_batch_with_file(period=None):
+    """找某个周期(或全局最新)里带原始文件的批次"""
+    conn = connect()
+    sql = ("SELECT * FROM batches WHERE file_path IS NOT NULL AND file_path<>''")
+    args = []
+    if period:
+        sql += " AND period=?"
+        args.append(period)
+    sql += " ORDER BY id DESC LIMIT 1"
+    r = conn.execute(sql, args).fetchone()
+    if not r and period:
+        r = conn.execute("SELECT * FROM batches WHERE file_path IS NOT NULL AND file_path<>'' "
+                         "ORDER BY id DESC LIMIT 1").fetchone()
+    conn.close()
+    return dict(r) if r else None
+
+
+def import_history(limit=20):
+    conn = connect()
+    rows = conn.execute("SELECT * FROM batches ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
+    conn.close()
+    return [dict(r) for r in rows]
+
+
+def stats():
+    conn = connect()
+    n_stores = conn.execute("SELECT COUNT(*) c FROM stores").fetchone()["c"]
+    n_metrics = conn.execute("SELECT COUNT(*) c FROM metrics").fetchone()["c"]
+    n_batches = conn.execute("SELECT COUNT(*) c FROM batches").fetchone()["c"]
+    conn.close()
+    return {"stores": n_stores, "metrics": n_metrics, "batches": n_batches}

+ 392 - 0
store-ops/backend/ingest.py

@@ -0,0 +1,392 @@
+# -*- coding: utf-8 -*-
+"""
+数据导入(CSV / Excel xlsx)
+- 编码:utf-8-sig → gbk → utf-8(容错)
+- 表头:走别名映射,抖音来客导出的不同列名都能认
+- 粒度:有日期列/传入日期 → 按日入库(daily);否则整表当周期汇总(period)
+- 幂等:同 门店+周期+日期+粒度 覆盖写入,重复导入不会翻倍
+"""
+import csv
+import io
+import os
+import re
+from datetime import datetime
+
+import db
+import xlsx
+
+# canonical -> 可能的表头写法(已归一化:去空格/全角括号转半角)
+ALIASES = {
+    "store_id": ["门店id", "门店编号", "门店编码", "poiid", "shopid", "storeid", "id"],
+    "name": ["门店名称", "门店名", "店铺名称", "shopname", "storename", "name"],
+    "city": ["所在城市", "城市", "city"],
+    "date": ["日期", "统计日期", "数据日期", "时间", "date"],
+    "visit": ["门店页访问次数", "访问次数", "门店访问", "访问量", "visit"],
+    "deal": ["门店页成交券数", "成交券数", "成交单量"],
+    "deal_refund": ["门店页成交后退款券数", "成交后退款券数"],
+    "intend_deal": ["门店意向成交券数", "意向成交券数", "下单券数", "成交券数(意向)"],
+    "used_amount": ["门店核销金额", "核销金额", "核销gmv", "核销交易额"],
+    "used_coupons": ["门店核销券数", "核销券数", "核销量", "核销单量"],
+    "intend_refund": ["门店意向退款券数", "意向退款券数"],
+    "videos": ["新发布门店关联视频数", "新发布视频数", "关联视频数", "发布视频数"],
+    "video_views": ["门店视频播放次数", "视频播放次数", "播放量"],
+    "score": ["门店评分", "评分", "score"],
+    "reviews_new": ["新增评价数", "新增评价"],
+    "good_new": ["新增好评数", "新增好评"],
+    "bad_new": ["新增中差评数", "新增差评数", "新增中差评"],
+    "reviews_total": ["累计评价数", "总评价数"],
+    "good_total": ["累计好评数", "总好评数"],
+    "bad_total": ["累计中差评数", "累计差评数"],
+    "reply_rate": ["评价回复率", "回复率"],
+    "complaint_rate": ["经营风险投诉率", "投诉率"],
+    "return_rate": ["经营风险商责退单率", "商责退单率", "退单率"],
+    "lit": ["门店点亮数", "点亮数"],
+    "lit_posts": ["点亮后的投稿数", "点亮投稿数"],
+    "favs": ["门店收藏数", "收藏数"],
+    "rank_note": ["上榜信息及排名", "榜单排名", "上榜信息"],
+}
+
+RATE_FIELDS = {"reply_rate", "complaint_rate", "return_rate"}
+INT_FIELDS = {"visit", "deal", "deal_refund", "intend_deal", "used_coupons",
+              "intend_refund", "videos", "video_views", "reviews_new", "good_new",
+              "bad_new", "reviews_total", "good_total", "bad_total", "lit", "lit_posts", "favs"}
+FLOAT_FIELDS = {"used_amount", "score"}
+TEXT_FIELDS = {"rank_note"}
+
+
+def norm_header(h):
+    h = (h or "").strip().replace("\ufeff", "")
+    h = h.replace("(", "(").replace(")", ")")
+    h = re.sub(r"\s+", "", h)
+    return h.lower()
+
+
+def build_map(header):
+    """返回 canonical -> 列下标"""
+    norm = [norm_header(h) for h in header]
+    m = {}
+    for canon, names in ALIASES.items():
+        for i, h in enumerate(norm):
+            if h in [n.lower() for n in names]:
+                m[canon] = i
+                break
+    return m
+
+
+def decode(raw):
+    for enc in ("utf-8-sig", "gbk", "utf-8"):
+        try:
+            return raw.decode(enc), enc
+        except UnicodeDecodeError:
+            continue
+    return raw.decode("utf-8", errors="replace"), "utf-8(replace)"
+
+
+def to_num(v, as_rate=False):
+    if v is None:
+        return None
+    s = str(v).strip().replace(",", "").replace(" ", "")
+    if s in ("", "-", "--", "None", "null", "nan"):
+        return None
+    has_pct = s.endswith("%")
+    if has_pct:
+        s = s[:-1]
+    try:
+        x = float(s)
+    except ValueError:
+        return None
+    if as_rate:
+        # 带 % 或数值大于 1,都视为百分数
+        if has_pct or x > 1:
+            x = x / 100.0
+    return x
+
+
+def _valid_ymd(y, mo, d):
+    """合法性校验:月份 1-12、日期 1-31,不合法返回 None(避免出现 2026-01-00 这种)"""
+    try:
+        y, mo, d = int(y), int(mo), int(d)
+    except (TypeError, ValueError):
+        return None
+    if not (1 <= mo <= 12) or not (1 <= d <= 31):
+        return None
+    return y, mo, d
+
+
+# 生意经「按统计周期」导出的统计日期是区间,如 2026-10-03-2026-11-01 / 2026-10-03~2026-11-01
+RANGE_RE = re.compile(
+    r"^\s*(20\d{2})[-/.]?(\d{1,2})[-/.](\d{1,2})\s*(?:-|~|~|至|到)\s*"
+    r"(20\d{2})[-/.]?(\d{1,2})[-/.](\d{1,2})\s*$")
+
+
+def _norm_date(y, m, d):
+    try:
+        return "%04d-%02d-%02d" % (int(y), int(m), int(d))
+    except (TypeError, ValueError):
+        return ""
+
+
+def parse_range(v):
+    """把统计日期区间解析成 (start, end);不是区间返回 None"""
+    m = RANGE_RE.match(str(v or "").replace(" ", ""))
+    if not m:
+        return None
+    a = _norm_date(m.group(1), m.group(2), m.group(3))
+    b = _norm_date(m.group(4), m.group(5), m.group(6))
+    if not a or not b or a > b:
+        return None
+    return a, b
+
+
+def range_in_filename(filename):
+    """文件名里带日期区间(如 …_2026-10-03_2026-11-01.xlsx)→ 返回 (start, end)"""
+    m = re.search(r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\s*[_~-]+\s*"
+                  r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})", str(filename or ""))
+    if not m:
+        return None
+    a = _norm_date(m.group(1), m.group(2), m.group(3))
+    b = _norm_date(m.group(4), m.group(5), m.group(6))
+    if a and b and a <= b:
+        return a, b
+    return None
+
+
+def guess_period_date(filename, period, date):
+    """
+    没给日期/月份时,从文件名猜。顺序:先认「年月日」,再认「年月」,最后认「月」。
+      日粒度:2026-10-30 / 2026/10/30 / 2026.10.30 / 2026年10月30日 / 20261030 / 10.28
+      月粒度:2026-10 / 2026年10月 / 2026.10 / 202610 / 10月
+    关键点:2026-10 这类「年月」不能被误读成「2026 年 1 月 0 日」——
+    所以完整日期必须分隔符齐全、且年月日都合法,才按日粒度处理。
+    """
+    if date and not period:
+        period = date[:7]
+    if date or period:
+        return period, date
+    fn = filename or ""
+
+    # 1) 带分隔符的完整日期:2026-10-30、2026/10/30、2026.10.30、2026年10月30日
+    m = re.search(r"(20\d{2})\s*[-_/.年]\s*(\d{1,2})\s*[-_/.月]\s*(\d{1,2})(?!\d)", fn)
+    if m:
+        ymd = _valid_ymd(*m.groups())
+        if ymd:
+            return "%04d-%02d" % ymd[:2], "%04d-%02d-%02d" % ymd
+
+    # 2) 无分隔符的完整日期:20261030
+    m = re.search(r"(?<!\d)(20\d{2})(\d{2})(\d{2})(?!\d)", fn)
+    if m:
+        ymd = _valid_ymd(*m.groups())
+        if ymd:
+            return "%04d-%02d" % ymd[:2], "%04d-%02d-%02d" % ymd
+
+    # 3) 带分隔符的年月:2026-10、2026年10月、2026.10
+    m = re.search(r"(20\d{2})\s*[-_/.年]\s*(\d{1,2})(?!\d)", fn)
+    if m:
+        y, mo = int(m.group(1)), int(m.group(2))
+        if 1 <= mo <= 12:
+            return "%04d-%02d" % (y, mo), ""
+
+    # 4) 无分隔符的年月:202610
+    m = re.search(r"(?<!\d)(20\d{2})(\d{2})(?!\d)", fn)
+    if m:
+        y, mo = int(m.group(1)), int(m.group(2))
+        if 1 <= mo <= 12:
+            return "%04d-%02d" % (y, mo), ""
+
+    # 5) 只有月份:10月 / 10.28(用当前年份)
+    y = datetime.now().year
+    m = re.search(r"(\d{1,2})\s*月", fn)
+    if m and 1 <= int(m.group(1)) <= 12:
+        return "%04d-%02d" % (y, int(m.group(1))), ""
+    m = re.search(r"(?<!\d)(\d{1,2})\.(\d{1,2})(?!\d)", fn)
+    if m:
+        ymd = _valid_ymd(y, m.group(1), m.group(2))
+        if ymd:
+            return "%04d-%02d" % ymd[:2], "%04d-%02d-%02d" % ymd
+
+    return datetime.now().strftime("%Y-%m"), ""
+
+
+def import_rows(rows, filename, period=None, date=None, source="csv", window=None, file_path=None):
+    """rows: [[str,...], ...],第一行为表头。CSV 与 Excel 共用此入库逻辑。"""
+    rows = [r for r in rows]
+    # 去掉全空行/表头前的杂讯行:找到含「门店名称或门店ID」表头的行作为表头
+    start = 0
+    for i, r in enumerate(rows[:20]):
+        normed = [norm_header(c) for c in r]
+        if any(h in ("门店id", "门店编号", "门店编码", "poiid") for h in normed) \
+           and any("门店名称" in h or h == "店铺名称" for h in normed):
+            start = i
+            break
+    rows = rows[start:]
+    if not rows:
+        return {"ok": False, "error": "文件为空"}
+
+    reader = rows
+    header = [str(c) if c is not None else "" for c in reader[0]]
+    cmap = build_map(header)
+    missing = [k for k in ("store_id", "name") if k not in cmap]
+    if missing:
+        return {"ok": False, "error": "表头缺少必要列:门店ID / 门店名称",
+                "header": header}
+
+    # window=(start, end) 时强制按「近 N 天快照」入库(生意经按统计周期导出没有日期列)
+    win_force = None
+    if not window:
+        window = range_in_filename(filename)
+    if window:
+        a, b = (window[0] or "").strip(), (window[1] or "").strip()
+        if a and b and a <= b:
+            win_force = (a, b)
+            if not period:
+                period = b
+            if not date:
+                date = b
+    period, date = guess_period_date(filename, period, date)
+
+    conn = db.connect()
+    today = datetime.now().date().isoformat()
+    rows_read = rows_written = new_stores = duplicates = 0
+    seen_keys = set()
+    errors = []
+    dates = set()
+    grams = set()          # 实际入库用到的粒度(daily / period / snapshot)
+    win_seen = set()       # 快照的统计窗口
+    period_cnt = {}        # 实际入库的周期分布,用于回报「导到哪个月了」
+    period_val = period
+
+    with conn:
+        batch_id = db.record_batch(conn, {"filename": filename, "rows_read": 0, "rows_written": 0,
+                                          "file_path": file_path,
+                                          "new_stores": 0, "period": period, "note": "importing"})
+        for line_no, r in enumerate(reader[1:], start=2):
+            r = [("" if c is None else str(c)) for c in r]
+            if not r or not any(c.strip() for c in r):
+                continue
+            rows_read += 1
+
+            def cell(key):
+                i = cmap.get(key)
+                return r[i] if i is not None and i < len(r) else None
+
+            sid = (cell("store_id") or "").strip()
+            name = (cell("name") or "").strip()
+            if not sid or not name:
+                if len(errors) < 5:
+                    errors.append("第%d行缺少门店ID或名称" % line_no)
+                continue
+
+            row_date = (cell("date") or "").strip()
+            win_start = win_end = ""
+            if row_date:
+                rng = parse_range(row_date)
+                if rng:                                  # 区间 → 近 N 天快照
+                    win_start, win_end = rng
+                else:
+                    row_date = row_date.replace("/", "-").replace(".", "-")
+                    m = re.match(r"(20\d{2})-(\d{1,2})-(\d{1,2})", row_date)
+                    if m:
+                        row_date = "%s-%02d-%02d" % (m.group(1), int(m.group(2)), int(m.group(3)))
+                    else:
+                        row_date = date or ""
+
+            if win_force:
+                win_start, win_end = win_force
+            if win_start and win_end and win_start != win_end:
+                # 滚动窗口:period 记「截至日」,每店一行,window_start/end 保留区间
+                p, g, row_date = win_end, "snapshot", win_end
+            elif win_start and win_end:
+                p, g, row_date = win_start[:7], "daily", win_start   # 首尾同一天 = 单日
+                win_start = win_end = ""
+            else:
+                p = row_date[:7] if row_date else period_val
+                g = "daily" if row_date else "period"
+
+            key = (sid, p, row_date, g)
+            if key in seen_keys:
+                duplicates += 1
+                if len(errors) < 5:
+                    errors.append("第%d行与其他行门店/日期重复,已跳过" % line_no)
+                continue
+            seen_keys.add(key)
+            if row_date:
+                dates.add(row_date)
+
+            rec = {"store_id": sid, "period": p, "date": row_date, "granularity": g,
+                   "window_start": win_start, "window_end": win_end}
+            for key in INT_FIELDS:
+                v = to_num(cell(key))
+                rec[key] = int(v) if v is not None else 0
+            for key in FLOAT_FIELDS:
+                rec[key] = to_num(cell(key))
+            for key in RATE_FIELDS:
+                rec[key] = to_num(cell(key), as_rate=True)
+            for key in TEXT_FIELDS:
+                rec[key] = (cell(key) or "").strip() or None
+
+            city = (cell("city") or "").strip()
+            if db.upsert_store(conn, sid, name, city, today):
+                new_stores += 1
+            db.insert_metric(conn, rec, batch_id)
+            rows_written += 1
+            grams.add(g)
+            period_cnt[p] = period_cnt.get(p, 0) + 1
+            if g == "snapshot" and win_start and win_end:
+                win_seen.add((win_start, win_end))
+
+        conn.execute("""UPDATE batches SET rows_read=?, rows_written=?, new_stores=?,
+                        period=?, date_from=?, date_to=?, note=? WHERE id=?""",
+                     (rows_read, rows_written, new_stores,
+                      (max(period_cnt.items(), key=lambda kv: kv[1])[0] if period_cnt
+                       else (sorted(dates)[0][:7] if dates else period_val)),
+                      min(dates) if dates else "", max(dates) if dates else "",
+                      "ok" if rows_written else "无有效数据", batch_id))
+    conn.close()
+
+    # 回包口径以「实际写进库的行」为准:跨月文件按行数最多的那个月回报
+    out_period = max(period_cnt.items(), key=lambda kv: kv[1])[0] if period_cnt else period_val
+    granularity = ("snapshot" if "snapshot" in grams
+                   else "daily" if "daily" in grams else "period")
+    return {"ok": rows_written > 0, "source": source, "filename": filename,
+            "rows_read": rows_read, "rows_written": rows_written,
+            "new_stores": new_stores, "granularity": granularity,
+            "period": out_period, "date": date,
+            "periods": sorted(period_cnt.items(), key=lambda kv: -kv[1]),
+            "windows": [{"start": a, "end": b} for a, b in sorted(win_seen)],
+            "date_range": [min(dates), max(dates)] if dates else [],
+            "skipped": rows_read - rows_written, "duplicates": duplicates, "errors": errors,
+            "columns_matched": len(cmap)}
+
+
+def import_csv(raw, filename, period=None, date=None, window=None, file_path=None):
+    text, enc = decode(raw)
+    sample = text[:4096]
+    try:
+        dialect = csv.Sniffer().sniff(sample, delimiters=",\t;")
+        delim = dialect.delimiter
+    except csv.Error:
+        delim = ","
+    rows = list(csv.reader(io.StringIO(text), delimiter=delim))
+    r = import_rows(rows, filename, period=period, date=date, source="csv", window=window, file_path=file_path)
+    r["encoding"] = enc
+    return r
+
+
+def import_xlsx(raw, filename, period=None, date=None, window=None, file_path=None):
+    rows, sheets = xlsx.read_bytes(raw)
+    r = import_rows(rows, filename, period=period, date=date, source="xlsx", window=window, file_path=file_path)
+    r["sheets"] = sheets
+    return r
+
+
+def import_bytes(raw, filename, period=None, date=None, window=None, file_path=None):
+    """按扩展名分发:.xlsx 走 Excel 解析,其余按 CSV。window=(start,end) 强制按快照入库。"""
+    if filename.lower().endswith(".xlsx"):
+        return import_xlsx(raw, filename, period=period, date=date, window=window, file_path=file_path)
+    return import_csv(raw, filename, period=period, date=date, window=window, file_path=file_path)
+
+
+def import_file(path, period=None, date=None, file_path=None):
+    with open(path, "rb") as fp:
+        raw = fp.read()
+    return import_bytes(raw, os.path.basename(path), period=period, date=date, file_path=file_path)

+ 543 - 0
store-ops/backend/rules.py

@@ -0,0 +1,543 @@
+# -*- coding: utf-8 -*-
+"""
+考核指标默认值 + 门店健康诊断规则(v2.1)
+
+设计依据:项目记录/规则全景图_可编辑.html(2026-11-01 用户确认版)
+- 按漏斗分 4 层:流量 → 转化 → 履约 → 口碑
+- 5 档:R 违规红线 / B 考核未达标 / T 运营提升 / N 样本不足
+- 核销链内部去重:零成交 > 成交未核销 > 核销率低(只报最上游一条)
+- 核销金额独立判定(用户 2026-11-01 定)
+- 1 棵诊断树选「主因」,问题全列,每个问题都给对应动作
+- 全项目唯一的规则实现,前端只负责渲染
+"""
+import re
+
+# 官方默认基准线(可在工作台【指标配置】修改,修改后存库)
+DEFAULT_THRESHOLDS = {
+    # —— 飞书《门店考核指标》官方基准 ——
+    "score_base": 4.3,
+    "score_target": 4.5,
+    "hx_rate_base": 0.4,
+    "hx_rate_target": 0.6,
+    "hx_amt_base": 10000,
+    "tuDan": [0.0127, 0.0318, 0.0635],    # 商责退单率 预警/一般严重/非常严重
+    "touSu": [0.0011, 0.0028, 0.0056],    # 投诉率
+    "chaPing": [0.0066, 0.0166, 0.0331],  # 差评率
+    "reply_base": 50,                      # 评价回复率(%) 百分数
+    # —— v2 新增:判定口径与门槛 ——
+    "visit_low": 100,        # 访问低于此次数 → 访问过低
+    "cvr_low": 10,           # 成交转化率(%) 低于此 → 转化低(2026-11-01 品牌口径)
+    "pend_high": 10,         # 未核销券积压达到此次数 → 积压提醒
+    "hx_min_deal": 5,        # 核销率判定要求的最小意向成交券数
+    "amt_min_coupons": 5,    # 「客单价偏低」判定要求的最少核销券数
+    "cp_min_new": 10,        # 差评率:新增口径最小样本
+    "cp_min_total": 20,      # 差评率:累计口径最小样本
+    "reply_min_new": 1,      # 回复率判定要求的最小新增评价数
+    "video_low": 10,         # 新发布视频低于此数 → 视频量少
+    "lit_low": 10,           # 门店点亮低于此数 → 点亮次数偏低
+    "new_review_low": 10,    # 新增评价低于此数 → 新增评价少
+}
+
+LEVEL_WEIGHT = {"R3": 3, "R2": 2, "R1": 1, "B": 0.6, "T": 0.3, "N": 0}
+LEVEL_NAME = {"R1": "预警线", "R2": "一般严重线", "R3": "非常严重线"}
+
+LAYER_NAME = {"traffic": "流量", "convert": "转化", "fulfill": "履约", "repute": "口碑"}
+
+# 建议动作库
+ACT_TD = "调整门店接待流程避免商责退单进一步加重,主动补20单核销好评,降低商责退单占比"
+ACT_TS = "调整门店接待流程避免客诉进一步加重"
+ACT_CP = "逐条处理近30天差评,主攻优质深度好评(避免无效评价)"
+ACT_SCORE = "优先引导带图深度好评,需先将门店评分提升至{base}以上"
+ACT_SCORE0 = "优先引导带图深度好评,达到开分门槛后评分才会生成"
+ACT_HX = "每天使用客资中心邀约未核销客户,设置到店核销激励"
+ACT_ZERO = "触达全部已成交客户确认到店意愿,核查门店核销流程是否卡单"
+ACT_AMT = "每月至少完成1.2w大单上翻,保持金牌门店,获取平台流量激励"
+ACT_REPLY = "安排时间定时回复新增评价,差评24小时内及时处理"
+ACT_VISIT = "1. 检查门店是否有违规限流\n2.点亮门店+更新AIGC素材,增加门店投稿,提升门店曝光"
+ACT_VISIT0 = "核查门店是否营业/被限流,确认团购在售,完成门店点亮"
+ACT_VIDEO = "每周用后台AIGC工具产出1条图文或视频"
+ACT_LIT = "店内需放置优惠码物料,积极引导到店客户点亮门店"
+ACT_CVR = "提高门店基建数据,增加进店转化"
+ACT_DEAL0 = "检查团购是否在售、门店是否有违规限流"
+ACT_PEND = "每天使用客资中心及时邀约新增客资"
+ACT_RVOL = "每月保持不低于15条新增优质评价"
+ACT_RANK0 = "连续7天交易上翻 + 主攻好评冲本地榜单;上榜可改善平台限流"
+ACT_RANK_IN = "在已入围榜单上持续做好评与交易增量,把名次冲上去"
+
+# ---------------------------------------------------------------------------
+# 文案模板(全部可在前端「文案配置」面板自定义,改后存库、全店重算)
+# 可用占位符见各条注释;未知占位符原样保留,不会报错
+# ---------------------------------------------------------------------------
+DEFAULT_TEXTS = {
+    # —— 反馈文案结构 ——
+    "fb_sec_overview": "✅经营数据概览:",
+    "fb_line_overview": "近30天:客资{客资} 核销{核销} 退单{退单}",
+    "fb_sec_risk": "✅风险或限流因素:",
+    "fb_item_risk": "{i}、{问题}",
+    "fb_line_pending": "✅后台待核销订单{待核销}单(估算未核销)",
+    "fb_sec_loss": "✅顾客流失分析(近30天):",
+    "fb_item_visit": "{i}、门店页有效人数 {访问} 人({访问环比})",
+    "fb_item_deal": "{i}、成交 {成交} 人(占比 {成交占比} )",
+    "fb_item_lost": "{i}、流失 {流失} 人(占比 {流失占比} )",
+    "fb_sec_insight": "✅分析洞察:",
+    "fb_item_insight": "{i}、{洞察}",
+    "fb_insight_metric": "指标层面:核销率仅 {核销率},距离 {核销目标} 基准线差距明显,是门店第一大限流指标,平台自然流量权重持续承压,推荐量会持续收紧。",
+    "fb_insight_score": "口碑分:团购评分 {评分} 低于平台准入基准 {评分基准},评分权重直接影响自然流量推荐与转化。",
+    "fb_insight_flow": "流量转化:门店页访客 {访问} 人,成交转化率仅 {成交占比},{流失占比} 访客流失,用户浏览后更容易被隔壁同类门店抢走。提升门店基建数据,增加进店转化。",
+    "fb_insight_order": "订单风险:后台积压 {待核销} 单待核销订单,存量体量较大,如不主动跟进邀约,后期极易集中产生退单、负面评价,进一步拉低评分,形成恶性循环。",
+    "fb_insight_reply": "口碑管理:评价回复率仅 {回复率},低于 {回复基准} 考核要求,差评无人回应会进一步压低门店权重。",
+    "fb_insight_priority": "整改优先级:优先电话 / 私信触达待核销客户,加速到店核销;服务环节引导优质评价拉升团购分。",
+    "fb_sec_action": "📌 门店调整动作",
+    "fb_no_issue": "本期无明显异常",
+    # —— 一句话结论(诊断树选出的主因)——
+    "fb_sec_headline": "✅本期结论:",
+    "fb_item_headline": "{结论}",
+    "hd_R": "有违规在限流:{问题} —— 平台已在处置或限流,先处理违规项再谈增长",
+    "hd_traffic": "卡在「流量」:{问题} —— 没有足够流量进店,后面几步都无从谈起",
+    "hd_convert": "卡在「转化」:{问题} —— 有流量但没下单,转化环节断掉了",
+    "hd_fulfill": "卡在「履约」:{问题} —— 成交了但没核销,钱卡在未履约这一环",
+    "hd_repute": "卡在「口碑」:{问题} —— 口碑在拖累自然流量与转化",
+    "hd_AMT": "核销金额没达标:{问题} —— 要靠大单上翻把金额做起来,才能保住金牌门店和流量激励",
+    "hd_SCORE0": "卡在「口碑」:{问题} —— 还没开分,先把有效评价量做起来",
+    "hd_OK": "本期各项指标均未触发问题",
+    # —— 整改动作条目(来自诊断树,最多 3 条)——
+    "fb_action_item": "✅ {动作}",
+    # —— 严重档位名称 ——
+    "lv_R1": "预警线",
+    "lv_R2": "一般严重线",
+    "lv_R3": "非常严重线",
+    # —— 问题文案:{v}=实际值 {base}=基准值 {口径}=新增/累计 {档}=档位名 ——
+    "t_TD": "商责退单率{v}%超{档}",
+    "t_TS": "投诉率{v}%超{档}",
+    "t_CP": "差评率({口径}口径){v}%超{档}",
+    "t_SCORE": "评分{v}(基准线{base})-低于平台准入",
+    "t_SCORE0": "未开分({原因}),暂无有效评分",
+    "t_HX": "核销率{v}%(基准线{base}%)",
+    "t_ZERO": "成交{n}张券一张未核销",
+    "t_AMT": "当月核销{金额}元,未做大单上翻",
+    "t_REPLY": "新增{新增}条评价,回复率{v}%偏低",
+    "t_VISIT": "门店曝光访问偏低",
+    "t_VIDEO": "门店发布视频数量少",
+    "t_LIT": "门店点亮次数偏低",
+    "t_CVR": "成交转化率仅{v}%",
+    "t_DEAL0": "有{v}次访问却零成交",
+    "t_PEND": "积压{p}张未核销券",
+    "t_RANK0": "未上榜(好评榜/人气榜均未进入)",
+    "t_RANK_IN": "仅入围未进榜:{榜}",
+    "t_RVOL": "近30天新增评价少",
+    # —— 整改建议(评分建议可用 {base} {target})——
+    "a_TD": ACT_TD,
+    "a_TS": ACT_TS,
+    "a_CP": ACT_CP,
+    "a_SCORE": ACT_SCORE,
+    "a_SCORE0": ACT_SCORE0,
+    "a_HX": ACT_HX,
+    "a_ZERO": ACT_ZERO,
+    "a_AMT": ACT_AMT,
+    "a_REPLY": ACT_REPLY,
+    "a_VISIT": ACT_VISIT,
+    "a_VIDEO": ACT_VIDEO,
+    "a_LIT": ACT_LIT,
+    "a_CVR": ACT_CVR,
+    "a_DEAL0": ACT_DEAL0,
+    "a_PEND": ACT_PEND,
+    "a_RVOL": ACT_RVOL,
+    "a_RANK0": ACT_RANK0,
+    "a_RANK_IN": ACT_RANK_IN,
+}
+
+# v1 的默认文案快照:用于迁移。
+# 库里存的是整份文案(用户点过「应用」就全量落库),改默认值后旧存档会盖住新默认。
+# 规则:存档值 == v1 默认值 → 说明用户没改过,直接采用新默认;否则视为用户自定义,保留。
+V1_TEXTS = {
+    "fb_sec_overview": "✅经营数据概览:",
+    "fb_line_overview": "近30天:客资{客资} 核销{核销} 退单{退单}",
+    "fb_sec_risk": "✅风险或限流因素:",
+    "fb_item_risk": "{i}、{问题}",
+    "fb_line_pending": "✅后台待核销订单{待核销}单(真实未核销)",
+    "fb_sec_loss": "✅顾客流失分析(近30天):",
+    "fb_item_visit": "{i}、门店页有效人数 {访问} 人({访问环比})",
+    "fb_item_deal": "{i}、成交 {成交} 人(占比 {成交占比} )",
+    "fb_item_lost": "{i}、流失 {流失} 人(占比 {流失占比} )",
+    "fb_sec_insight": "✅分析洞察:",
+    "fb_item_insight": "{i}、{洞察}",
+    "fb_insight_metric": "指标层面:核销率仅 {核销率},距离 {核销目标} 基准线差距明显,是门店第一大限流指标,平台自然流量权重持续承压,推荐量会持续收紧。",
+    "fb_insight_score": "口碑分:团购评分 {评分} 低于平台准入基准 {评分基准},评分权重直接影响自然流量推荐与转化。",
+    "fb_insight_flow": "流量转化:门店页访客 {访问} 人,成交转化率仅 {成交占比},{流失占比} 访客流失,用户浏览后更容易被隔壁同类门店抢走,门店团购套餐、主页吸引力不足。",
+    "fb_insight_order": "订单风险:后台积压 {待核销} 单待核销订单,存量体量较大,如不主动跟进邀约,后期极易集中产生退单、负面评价,进一步拉低评分,形成恶性循环。",
+    "fb_insight_reply": "口碑管理:评价回复率仅 {回复率},低于 {回复基准} 考核要求,差评无人回应会进一步压低门店权重。",
+    "fb_insight_priority": "整改优先级:优先电话 / 私信触达待核销客户,加速到店核销;服务环节引导优质评价拉升团购分。",
+    "fb_sec_action": "📌 门店调整动作",
+    "fb_actions": "✅ 做评价:主攻优质深度好评\n避免短期,避免无效评价,长期做,稳定做,多渠道来客的都友好引导写评价,避免强硬逼迫写评价!\n✅ 传素材:后台 AIGC 内容定期更\n定期使用后台的AIGC素材工具,上传并更新高质量的图片与视频内容。这是吸引用户点击、提升页面转化率的基础。\n✅ 亮门店:每日记得点亮门店\n需长期执行「门店点亮」动作,通过在店内设置引导物料和准备小额礼品,主动激励客户完成打卡、评价等行为,将线下流量有效反哺线上。\n✅ 冲榜单:全力抢占本地榜单席位\n榜单能增加投放转化能力(有商责退单的如果上榜单可改善平台限流情况)\n✅ 每月连续7天交易上翻,总核销金额不低于{核销金额基准}元",
+    "fb_no_issue": "本期无明显异常",
+    # —— 一句话结论(诊断树选出的主因)——
+    "fb_sec_headline": "✅本期结论:",
+    "fb_item_headline": "{结论}",
+    "hd_R": "有违规在限流:{问题} —— 平台已在处置或限流,先处理违规项再谈增长",
+    "hd_traffic": "卡在「流量」:{问题} —— 没有足够流量进店,后面几步都无从谈起",
+    "hd_convert": "卡在「转化」:{问题} —— 有流量但没下单,转化环节断掉了",
+    "hd_fulfill": "卡在「履约」:{问题} —— 成交了但没核销,钱卡在未履约这一环",
+    "hd_repute": "卡在「口碑」:{问题} —— 口碑在拖累自然流量与转化",
+    "hd_AMT": "核销金额没达标:{问题} —— 要靠大单上翻把金额做起来,才能保住金牌门店和流量激励",
+    "hd_SCORE0": "卡在「口碑」:{问题} —— 还没开分,先把有效评价量做起来",
+    "hd_OK": "本期各项指标均未触发问题",
+    # —— 整改动作条目(来自诊断树,最多 3 条)——
+    "fb_action_item": "✅ {动作}",
+    "lv_R1": "预警线", "lv_R2": "一般严重线", "lv_R3": "非常严重线",
+    "t_TD": "商责退单率{v}%超{档}",
+    "t_TS": "投诉率{v}%超线",
+    "t_CP": "差评率({口径}口径){v}%超线",
+    "t_SCORE": "评分{v}(基准线{base})-低于平台准入",
+    "t_HX": "核销率{v}%(基准线{base}%)",
+    "t_ZERO": "本期零核销",
+    "t_AMT": "核销金额{v}元低于基准{base}",
+    "t_REPLY": "评价回复率{v}%偏低",
+    "t_VISIT": "访问仅{v}次",
+    "t_VIDEO": "本期零视频",
+    "a_TD": "催办到店核销、主动处理退单商责, 避免平台处置",
+    "a_TS": "24小时内响应投诉工单, 复盘服务环节",
+    "a_CP": "处理近30天差评, 主攻优质深度好评",
+    "a_SCORE": "引导好评+回复差评, 目标先回{base}再冲{target}",
+    "a_HX": "电话/私信触达待核销客户, 加速到店",
+    "a_ZERO": "检查团购在售状态与价格, 重新起量",
+    "a_AMT": "补齐在售品结构, 冲单量",
+    "a_REPLY": "安排专人回复全部评价",
+    "a_VISIT": "检查POI完整度, 点亮门店+更新AIGC素材",
+    "a_VIDEO": "后台AIGC工具定期产出图片/视频",
+}
+
+# 规则 key → 问题类型(筛选项/图表分类,固定不变,不受文案修改影响)
+RULE_CAT = {
+    "TD": "退单率超线", "TS": "投诉率超线", "CP": "差评率超线",
+    "SCORE": "评分不达标",
+    "REPLY": "回复率偏低", "RVOL": "新增评价少",
+    "DEAL0": "零成交", "CVR": "转化率低",
+    "ZERO": "成交未核销", "HX": "核销率不达标", "AMT": "客单价偏低", "PEND": "未核销积压",
+    "VISIT": "访问过低", "VIDEO": "视频量少", "LIT": "点亮偏低",
+    "RANK0": "未上榜", "RANK_IN": "仅入围",
+}
+
+# 规则元信息:(key, tier, layer)
+RULES = [
+    ("TD", "R", "repute"), ("TS", "R", "repute"), ("CP", "R", "repute"),
+    ("SCORE", "B", "repute"), ("REPLY", "B", "repute"), ("RVOL", "T", "repute"),
+    ("DEAL0", "B", "convert"),
+    ("ZERO", "B", "fulfill"), ("HX", "B", "fulfill"), ("AMT", "B", "fulfill"),
+    ("VISIT", "T", "traffic"),
+    ("VIDEO", "T", "traffic"), ("LIT", "T", "traffic"),
+    ("RANK0", "T", "traffic"), ("RANK_IN", "T", "traffic"),
+    ("CVR", "T", "convert"), ("PEND", "T", "fulfill"),
+]
+
+# 诊断树:漏斗顺序,取第一个命中作「主因」
+# "R" 表示"任一 R 级问题",命中时取违规级别最高的那条
+DIAGNOSIS_ORDER = ["R", "VISIT", "DEAL0", "CVR",
+                   "ZERO", "HX", "AMT", "SCORE", "REPLY", "RVOL",
+                   "VIDEO", "LIT", "PEND", "RANK0", "RANK_IN"]
+
+HEADLINE = {
+    "R": "平台已在处置或限流,先处理违规项再谈增长",
+    "traffic": "没有足够流量进店,后面几步都无从谈起",
+    "convert": "有流量但没下单,转化环节断掉了",
+    "fulfill": "成交了但没核销,钱卡在未履约这一环",
+    "repute": "口碑在拖累自然流量与转化",
+    "OK": "本期各项指标均未触发问题",
+}
+
+
+class _SafeDict(dict):
+    def __missing__(self, key):
+        return "{" + key + "}"
+
+
+def fmt_tpl(tpl, **vars):
+    """模板填充:未知占位符原样保留"""
+    try:
+        return str(tpl).format_map(_SafeDict(**vars))
+    except Exception:
+        return str(tpl)
+
+
+def _clean_action(a):
+    """动作条目去掉行首的 ✅/✓ 与空白,避免和「✅ {动作}」模板叠加"""
+    return re.sub(r"^[\s\u2705\u2714\u2611\ufe0f]+", "", str(a or "")).strip()
+
+
+def _money(v):
+    try:
+        return "{:,.0f}".format(float(v or 0))
+    except (TypeError, ValueError):
+        return "0"
+
+
+def band(v, lines):
+    """违规三级:返回值 None / R1 / R2 / R3"""
+    if v is None:
+        return None
+    if v > lines[2]:
+        return "R3"
+    if v > lines[1]:
+        return "R2"
+    if v > lines[0]:
+        return "R1"
+    return None
+
+
+def diff_rate(used_coupons, intend_deal):
+    """核销率 = 核销券数 ÷ 意向成交券数"""
+    if intend_deal and intend_deal > 0:
+        return used_coupons / intend_deal
+    return None
+
+
+def bad_rate(reviews_new, bad_new, reviews_total, bad_total):
+    """
+    差评率口径:
+      新增评价 ≥ cp_min_new        → 新增口径,样本充足,可判三级
+      累计评价 ≥ cp_min_total      → 累计口径,样本充足,可判三级
+      累计评价 > 0                 → 累计口径,小样本,最高只判「预警线」
+      新增评价 > 0                 → 新增口径,小样本,最高只判「预警线」
+      完全无评价                    → 不判定
+    返回 (ratio, 口径, 是否小样本)
+    """
+    rn = reviews_new or 0
+    bn = bad_new or 0
+    rt = reviews_total or 0
+    bt = bad_total or 0
+    if rn >= 10:
+        return bn / rn, "新增", False
+    if rt >= 20:
+        return bt / rt, "累计", False
+    if rt > 0:
+        return bt / rt, "累计", True
+    if rn > 0:
+        return bn / rn, "新增", True
+    return None, "", False
+
+
+def diagnose(m, T, X=None):
+    """
+    单店诊断。m 为聚合后的指标 dict,T 为阈值,X 为文案模板。
+    返回 dict:
+      problems   全部命中的问题;每条含 tpl/atpl/vars,前端可直接用改后的模板重渲染
+      primary    主因(诊断树第一个命中),没有则 None
+      secondary  次要问题(R/B 级,最多 2 条)
+      actions    整改动作(去重后最多 3 条)
+      sev        严重度(0~3)
+      tier       最高档位 R/B/T/OK
+      headline   一句话结论
+    """
+    X = X or DEFAULT_TEXTS
+    P = []
+
+    def add(key, tier, layer, level, tpl, atpl, **vars):
+        P.append({
+            "key": key, "tier": tier, "layer": layer, "level": level,
+            "cat": RULE_CAT.get(key, "其他"),
+            "tpl": tpl, "atpl": atpl, "vars": vars,
+            "text": fmt_tpl(X.get(tpl, ""), **vars) if X.get(tpl) else "",
+            "action": fmt_tpl(X.get(atpl, ""), **vars) if X.get(atpl) else "",
+        })
+
+    visit = m.get("visit") or 0
+    # 口径(2026-11-01):核销率分母、待核销、零成交、转化率分子统一用「门店页成交券数」
+    intend = m.get("deal") or 0
+    used = m.get("used_coupons") or 0
+    amt = m.get("used_amount") or 0
+    score = m.get("score")
+    rn = m.get("reviews_new") or 0
+    rt = m.get("reviews_total") or 0
+    videos = m.get("videos") or 0
+    lit = m.get("lit") or 0
+    pend = max(intend - used, 0)
+    hx = diff_rate(used, intend)
+    cvr = (intend / visit) if visit > 0 else None
+
+    # ================= 口碑层 =================
+    b = band(m.get("return_rate"), T["tuDan"])
+    if b:
+        add("TD", "R", "repute", b, "t_TD", "a_TD",
+            v="%.2f" % (m["return_rate"] * 100), 档=X["lv_" + b])
+
+    b = band(m.get("complaint_rate"), T["touSu"])
+    if b:
+        add("TS", "R", "repute", b, "t_TS", "a_TS",
+            v="%.2f" % (m["complaint_rate"] * 100), 档=X["lv_" + b])
+
+    cp, cp_src, cp_small = bad_rate(rn, m.get("bad_new"), rt, m.get("bad_total"))
+    b = band(cp, T["chaPing"])
+    if b:
+        if cp_small and b != "R1":
+            b = "R1"                       # 小样本最高只判预警线
+        add("CP", "R", "repute", b, "t_CP", "a_CP",
+            口径=cp_src, v="%.2f" % (cp * 100), 档=X["lv_" + b])
+
+    # 评分:0 分是「未开分」,仍算不达标,但文案与动作分开(用户 2026-11-01 定)
+    if score is not None and score < T["score_base"]:
+        if score == 0:
+            add("SCORE", "B", "repute", "B", "t_SCORE0", "a_SCORE0",
+                原因=("暂无评价" if rt == 0 else "评价量不足"))
+        else:
+            add("SCORE", "B", "repute", "B", "t_SCORE", "a_SCORE",
+                v="%.1f" % score, base=T["score_base"], target=T["score_target"])
+
+    reply = m.get("reply_rate")
+    if reply is not None and rn >= T["reply_min_new"] and reply < T["reply_base"] / 100.0:
+        add("REPLY", "B", "repute", "B", "t_REPLY", "a_REPLY",
+            v="%.0f" % (reply * 100), 新增=rn)
+
+    # 新增评价量:低于门槛 → 评价量不足,属运营提升项
+    if rn < T["new_review_low"]:
+        add("RVOL", "T", "repute", "T", "t_RVOL", "a_RVOL", n=rn)
+
+    # ================= 转化层 =================
+    # 四层是并行关系:转化层的问题不会压制履约层,各层各自报自己的
+    if visit > 0 and intend == 0:
+        add("DEAL0", "B", "convert", "B", "t_DEAL0", "a_DEAL0", v=visit)
+
+    # ================= 履约层(层内按根因去重:成交未核销 > 核销率低)=================
+    if intend > 0 and used == 0:
+        add("ZERO", "B", "fulfill", "B", "t_ZERO", "a_ZERO", n=intend)
+    elif intend >= T["hx_min_deal"] and hx is not None and hx < T["hx_rate_base"]:
+        add("HX", "B", "fulfill", "B", "t_HX", "a_HX",
+            v="%.0f" % (hx * 100), base="%.0f" % (T["hx_rate_base"] * 100))
+
+    # 核销金额:独立判定(用户 2026-11-01 定),不再受核销率约束
+    if amt < T["hx_amt_base"]:
+        add("AMT", "B", "fulfill", "B", "t_AMT", "a_AMT",
+            n=used, 金额=_money(amt), 客单价=_money(amt / used if used else 0))
+
+    # ================= 流量层(T 级提升项)=================
+    if visit < T["visit_low"]:
+        add("VISIT", "T", "traffic", "T", "t_VISIT", "a_VISIT", v=visit)
+    if videos < T["video_low"]:
+        add("VIDEO", "T", "traffic", "T", "t_VIDEO", "a_VIDEO", v=videos)
+    if lit < T["lit_low"] and visit > 0:
+        add("LIT", "T", "traffic", "T", "t_LIT", "a_LIT", v=lit)
+
+    # 上榜:有内容才算上榜;只有「入围」没有名次单独提示
+    rank = (m.get("rank_note") or "").strip()
+    ranked = rank not in ("", "-", "无")
+    if not ranked:
+        add("RANK0", "T", "traffic", "T", "t_RANK0", "a_RANK0")
+    elif not re.search(r"第\s*\d+\s*名", rank):
+        add("RANK_IN", "T", "traffic", "T", "t_RANK_IN", "a_RANK_IN",
+            榜=rank.split("、")[0])
+
+    # ================= 转化效率 / 积压 =================
+    if visit >= 100 and intend > 0 and cvr is not None and cvr < T["cvr_low"] / 100.0:
+        add("CVR", "T", "convert", "T", "t_CVR", "a_CVR", v="%.1f" % (cvr * 100))
+    if pend >= T["pend_high"]:
+        add("PEND", "T", "fulfill", "T", "t_PEND", "a_PEND", p=pend)
+
+    # ================= 诊断树:选主因 =================
+    by_key = {}
+    for p in P:
+        by_key.setdefault(p["key"], p)
+    primary = None
+    for step in DIAGNOSIS_ORDER:
+        if step == "R":
+            rs = [p for p in P if p["tier"] == "R"]
+            if rs:
+                primary = max(rs, key=lambda x: LEVEL_WEIGHT[x["level"]])
+                break
+        elif step in by_key:
+            primary = by_key[step]
+            break
+
+    rest = [p for p in P if p is not primary]
+    secondary = sorted([p for p in rest if p["tier"] in ("R", "B")],
+                       key=lambda x: -LEVEL_WEIGHT[x["level"]])[:2]
+
+    # 动作:每个问题都给对应动作(用户 2026-11-01 定:存在什么问题就给对应动作),不再限 3 条
+    actions = []
+    for p in ([primary] if primary else []) + [x for x in P if x is not primary]:
+        act = _clean_action(p["action"])
+        if act and act not in actions:
+            actions.append(act)
+
+    sev = max((LEVEL_WEIGHT[p["level"]] for p in P), default=0)
+    if any(p["tier"] == "R" for p in P):
+        tier = "R"
+    elif any(p["tier"] == "B" for p in P):
+        tier = "B"
+    elif P:
+        tier = "T"
+    else:
+        tier = "OK"
+
+    if primary is None:
+        hd_key = "hd_OK"
+        headline = X.get(hd_key) or HEADLINE["OK"]
+    else:
+        # 结论句回退链:规则专属 → 模板专属(区分「未开分」)→ 该层通用 → OK
+        cands = ["hd_" + primary["key"]]
+        tpl = primary.get("tpl") or ""
+        if tpl.startswith("t_"):
+            cands.append("hd_" + tpl[2:])
+        cands.append("hd_R" if primary["tier"] == "R" else "hd_" + primary["layer"])
+        cands.append("hd_OK")
+        hd_key = next((c for c in cands if c in X), "hd_OK")
+        headline = fmt_tpl(X[hd_key], 问题=primary["text"])
+
+    return {
+        "problems": P,
+        "primary": primary,
+        "secondary": secondary,
+        "actions": actions,
+        "sev": sev,
+        "tier": tier,
+        "headline": headline,
+        "hd_key": hd_key,
+    }
+
+
+def detect(m, T, X=None):
+    """兼容旧接口:返回 (flags, sev),flags 为 [level, 文案, 建议动作, 规则key]"""
+    d = diagnose(m, T, X)
+    flags = [[p["level"], p["text"], p["action"], p["key"]] for p in d["problems"]]
+    return flags, d["sev"]
+
+
+def cat_of(label):
+    """按文案反查问题类型(文案被改过时不可靠,筛选用 RULE_CAT[key])"""
+    for prefix, key in (("商责退单率", "TD"), ("投诉率", "TS"), ("差评率", "CP"),
+                        ("评分", "SCORE"), ("未开分", "SCORE0"), ("核销率", "HX"),
+                        ("成交", "ZERO"), ("核销", "AMT"), ("回复率", "REPLY"),
+                        ("访问", "VISIT"), ("视频", "VIDEO"), ("点亮", "LIT"),
+                        ("转化", "CVR"), ("积压", "PEND")):
+        if label.startswith(prefix):
+            return RULE_CAT[key]
+    return "其他"
+
+
+def validate(T):
+    """校验阈值合法性,返回错误列表(空=通过)"""
+    errs = []
+    try:
+        for k in ("score_base", "score_target", "hx_rate_base", "hx_rate_target",
+                  "hx_amt_base", "reply_base", "visit_low", "cvr_low", "pend_high",
+                  "hx_min_deal", "amt_min_coupons", "cp_min_new", "cp_min_total",
+                  "reply_min_new"):
+            if k in T:
+                T[k] = float(T[k])
+        for k in ("tuDan", "touSu", "chaPing"):
+            T[k] = [float(x) for x in T[k]]
+            a = T[k]
+            if not (a[0] > 0 and a[1] > a[0] and a[2] > a[1]):
+                errs.append("三级阈值需递增")
+        if T["score_base"] >= T["score_target"]:
+            errs.append("评分目标值应高于考核基准")
+        if T["hx_rate_base"] >= T["hx_rate_target"]:
+            errs.append("核销率目标值应高于考核基准")
+        if T["hx_amt_base"] < 0 or T["visit_low"] < 0:
+            errs.append("金额/访问线不能为负")
+        if T.get("cvr_low", 0) < 0 or T.get("pend_high", 0) < 0:
+            errs.append("转化率/积压线不能为负")
+        if T.get("cp_min_new", 0) <= 0 or T.get("cp_min_total", 0) <= 0:
+            errs.append("差评率最小样本量需大于 0")
+        if T.get("hx_min_deal", 0) < 1 or T.get("amt_min_coupons", 0) < 1:
+            errs.append("核销率最小成交券数需 ≥ 1")
+    except (TypeError, ValueError, KeyError):
+        errs.append("阈值格式不正确")
+    return errs

+ 852 - 0
store-ops/backend/server.py

@@ -0,0 +1,852 @@
+# -*- coding: utf-8 -*-
+"""
+门店检测工作台 —— 后端服务(Python 标准库,无需安装任何依赖)
+
+启动:
+    python backend/server.py            # 默认监听 0.0.0.0:8787(本机 + 局域网可访问)
+    python backend/server.py --local    # 只允许本机访问(127.0.0.1)
+    python backend/server.py --port 9000 --open
+
+接口:
+    GET  /api/meta                 周期列表 / 阈值 / 导入历史 / 统计
+    GET  /api/dashboard?period=    工作台全量数据(聚合指标 + 检测结果 + KPI + 分布)
+    PUT  /api/thresholds           保存自定义考核指标
+    POST /api/thresholds/reset     恢复官方默认
+    POST /api/import?filename=&period=&date=   导入 CSV(body 为原始字节)
+    GET  /api/export.csv?...       导出检测清单
+    GET  /api/history              导入批次历史
+
+目录:
+    frontend/   静态页面
+    data/       数据库 app.db、inbox/(丢文件进来自动导入)、processed/
+"""
+import json
+import mimetypes
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import threading
+import time
+import traceback
+import urllib.parse
+import webbrowser
+from datetime import datetime, timedelta
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+import auth
+import codesync
+import db
+import ingest
+import rules
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(BASE)
+FRONTEND = os.path.join(ROOT, "frontend")
+SYNC_SCRIPT = os.path.join(ROOT, "sync", "life_sync.mjs")
+SYNC_HOUR = 1          # 每天凌晨 1:00 自动同步近30天数据
+SYNC_CATCHUP_HOURS = 20  # 距上次成功超过这么久,启动时补跑一次
+
+
+# ----------------------------------------------------------------------------
+# 业务组装
+# ----------------------------------------------------------------------------
+def build_dashboard(period=None):
+    T = db.get_thresholds()
+    X = db.get_texts()
+    period = period or db.latest_period()
+    if not period:
+        return {"empty": True, "thresholds": T, "defaults": rules.DEFAULT_THRESHOLDS,
+                "texts": X, "text_defaults": rules.DEFAULT_TEXTS,
+                "periods": [], "stores": [], "kpis": [], "dist": {}, "period": None}
+
+    rows = db.aggregate_period(period)
+    stores = []
+    for m in rows:
+        d = rules.diagnose(m, T, X)
+        stores.append({
+            "id": m["id"], "name": m["name"], "city": m["city"],
+            "score": m["score"], "hx_rate": m["hx_rate"], "hx_amt": m["used_amount"],
+            "hx_cnt": m["used_coupons"], "td": m["return_rate"] or 0,
+            "ts": m["complaint_rate"] or 0, "cp": m["cp_rate"], "cp_src": m["cp_src"],
+            "reply": m["reply_rate"], "visit": m["visit"], "videos": m["videos"],
+            "lit": m.get("lit", 0),
+            "deal": m.get("deal", 0), "deal_refund": m.get("deal_refund", 0),
+            "intend_deal": m["intend_deal"], "days": m["days"],
+            "rank": m.get("rank_note") or "",
+            # v2 诊断结果(瘦身版:只传模板键+变量,文案由前端按当前模板渲染)
+            "tier": d["tier"], "headline": d["headline"], "hd_key": d["hd_key"],
+            "primary_key": (d["primary"] or {}).get("key"),
+            "sev": d["sev"],
+            "problems": [{"k": p["key"], "t": p["tier"], "l": p["layer"], "v": p["level"],
+                          "c": p["cat"], "p": p["tpl"], "a": p["atpl"], "x": p["vars"]}
+                         for p in d["problems"]],
+        })
+    stores.sort(key=lambda x: (-x["sev"], x["hx_amt"]))
+
+    kpis = [
+        {"k": "ALL", "label": "全部门店", "value": len(stores), "hot": 0},
+        {"k": "R", "label": "超违规预警线(退单/投诉/差评)", "value": sum(
+            1 for s in stores if s["tier"] == "R"), "hot": 1},
+        {"k": "SCORE", "label": "评分低于基准%s" % T["score_base"], "value": sum(
+            1 for s in stores if s["score"] is not None and s["score"] < T["score_base"]), "hot": 1},
+        {"k": "HX", "label": "核销率低于基准%.0f%%" % (T["hx_rate_base"] * 100), "value": sum(
+            1 for s in stores if s["hx_rate"] is not None and s["hx_rate"] < T["hx_rate_base"]), "hot": 1},
+        {"k": "ZERO", "label": "本期零核销", "value": sum(1 for s in stores if s["hx_cnt"] == 0), "hot": 1},
+    ]
+
+    cat_cnt = {}
+    for s in stores:
+        for c in {p["c"] for p in s["problems"]}:
+            cat_cnt[c] = cat_cnt.get(c, 0) + 1
+    amount_buckets = {"zero": 0, "lt1k": 0, "ltbase": 0, "ok": 0}
+    for s in stores:
+        a = s["hx_amt"] or 0
+        if a == 0:
+            amount_buckets["zero"] += 1
+        elif a < 1000:
+            amount_buckets["lt1k"] += 1
+        elif a < T["hx_amt_base"]:
+            amount_buckets["ltbase"] += 1
+        else:
+            amount_buckets["ok"] += 1
+    city_cnt = {}
+    for s in stores:
+        city_cnt[s["city"] or "未知"] = city_cnt.get(s["city"] or "未知", 0) + 1
+
+    return {
+        "empty": False,
+        "period": period,
+        "periods": db.get_periods(),
+        "thresholds": T,
+        "defaults": rules.DEFAULT_THRESHOLDS,
+        "texts": X,
+        "text_defaults": rules.DEFAULT_TEXTS,
+        "customized": T != codesync.get_factory(),
+        "kpis": kpis,
+        "stores": stores,
+        "dist": {
+            "cats": sorted(cat_cnt.items(), key=lambda x: x[1]),
+            "amount": amount_buckets,
+            "cities": sorted(city_cnt.items(), key=lambda x: -x[1])[:15],
+        },
+        "stats": db.stats(),
+    }
+
+
+def build_export(query):
+    """导出门店实际数据(纯指标,不含诊断结论/问题/整改动作)"""
+    d = build_dashboard(query.get("period", [None])[0])
+    rows = d["stores"]
+    kpi = query.get("kpi", ["ALL"])[0]
+    city = query.get("city", [""])[0]
+    cat = query.get("cat", [""])[0]
+    score_band = query.get("score", [""])[0]
+    q = query.get("q", [""])[0].lower()
+
+    if kpi == "R":
+        rows = [s for s in rows if s["tier"] == "R"]
+    elif kpi == "SCORE":
+        rows = [s for s in rows if s["score"] is not None and s["score"] < d["thresholds"]["score_base"]]
+    elif kpi == "HX":
+        rows = [s for s in rows if s["hx_rate"] is not None and s["hx_rate"] < d["thresholds"]["hx_rate_base"]]
+    elif kpi == "ZERO":
+        rows = [s for s in rows if s["hx_cnt"] == 0]
+    if city:
+        rows = [s for s in rows if s["city"] == city]
+    if cat:
+        rows = [s for s in rows if cat in {p["c"] for p in (s.get("problems") or [])}]
+    if score_band:
+        # 评分档位:闭区间按【】语义含端点(4.3 同时落在 4.0-4.3 与 4.3-4.5)
+        def in_band(v):
+            if v is None:
+                return False
+            x = round(v, 1)
+            if score_band == "lt40":
+                return x < 4.0
+            if score_band == "40_43":
+                return 4.0 <= x <= 4.3
+            if score_band == "43_45":
+                return 4.3 <= x <= 4.5
+            if score_band == "gte45":
+                return x >= 4.5
+            return True
+        rows = [s for s in rows if in_band(s["score"])]
+    if q:
+        rows = [s for s in rows if q in s["name"].lower() or q in s["id"]]
+
+    def pct(v, n=1):
+        return "" if v is None else ("%." + str(n) + "f%%") % (v * 100)
+
+    head = ["门店ID", "门店名称", "城市", "评分", "访问次数", "成交券数", "核销券数",
+            "核销率", "核销金额", "待核销券(估算)", "商责退单率", "投诉率", "差评率",
+            "评价回复率", "发布视频数", "门店点亮数", "上榜信息及排名"]
+    lines = [",".join(head)]
+    for s in rows:
+        pend = max((s["deal"] or 0) - (s["hx_cnt"] or 0), 0)
+        cells = [s["id"], s["name"], s["city"],
+                 "" if s["score"] is None else "%.1f" % s["score"],
+                 str(s["visit"]), str(s["deal"]), str(s["hx_cnt"]),
+                 pct(s["hx_rate"]), "%.0f" % (s["hx_amt"] or 0), str(pend),
+                 "%.2f%%" % (s["td"] * 100), "%.2f%%" % (s["ts"] * 100),
+                 "" if s["cp"] is None else "%.2f%%" % (s["cp"] * 100),
+                 "" if s["reply"] is None else "%.0f%%" % (s["reply"] * 100),
+                 str(s["videos"]), str(s.get("lit") or 0),
+                 s.get("rank") or ""]
+        # 单元格内的换行压成空格,避免 Excel 里出现多行单元格
+        lines.append(",".join(
+            '"%s"' % str(c).replace("\r", " ").replace("\n", " ").replace('"', '""')
+            for c in cells))
+    return ("\ufeff" + "\r\n".join(lines)).encode("utf-8"), d["period"]
+
+
+# ----------------------------------------------------------------------------
+# 近30天数据每日自动同步(生意经)
+# 放在服务进程里跑:launchd 会被 macOS 的 ~/Documents 保护挡住,服务本身没有这个问题
+# ----------------------------------------------------------------------------
+SYNC_LOCK = threading.Lock()
+SYNC_STATE = {"running": False, "last_start": None, "last_end": None,
+              "last_ok": None, "last_msg": "", "trigger": ""}
+
+
+def _ego_bin():
+    """定位 ego-browser:PATH 里没有时按常见安装位置找"""
+    import shutil
+    for c in (os.environ.get("EGO_BIN"),
+              os.path.expanduser("~/.local/bin/ego-browser"),
+              "/usr/local/bin/ego-browser", "/opt/homebrew/bin/ego-browser"):
+        if c and os.path.isfile(c) and os.access(c, os.X_OK):
+            return c
+    return shutil.which("ego-browser")
+
+
+def run_sync(trigger="manual"):
+    """跑一次同步;同一时间只允许一个"""
+    if not SYNC_LOCK.acquire(blocking=False):
+        return {"ok": False, "error": "已有同步任务在进行中"}
+    try:
+        if not os.path.isfile(SYNC_SCRIPT):
+            return {"ok": False, "error": "找不到同步脚本 %s" % SYNC_SCRIPT}
+        ego = _ego_bin()
+        if not ego:
+            return {"ok": False, "error": "找不到 ego-browser"}
+
+        SYNC_STATE.update({"running": True, "trigger": trigger,
+                           "last_start": datetime.now().isoformat(timespec="seconds"),
+                           "last_msg": ""})
+        print("[sync] 开始(%s)" % trigger)
+        code = ("globalThis.SYNC_ARGS = [];"
+                "await import('file://' + encodeURI(%r));" % SYNC_SCRIPT)
+        pr = subprocess.run([ego, "nodejs", "-e", code], cwd=ROOT,
+                            capture_output=True, text=True, timeout=1800)
+        out = (pr.stdout or "") + (pr.stderr or "")
+        ok = pr.returncode == 0
+        tail = [l for l in out.strip().splitlines() if l.strip()][-3:]
+        SYNC_STATE.update({"running": False, "last_ok": ok,
+                           "last_end": datetime.now().isoformat(timespec="seconds"),
+                           "last_msg": " | ".join(tail)[:500]})
+        db.set_setting("last_sync", datetime.now().isoformat(timespec="seconds"))
+        print("[sync] 结束 ok=%s" % ok)
+        return {"ok": ok, "output": tail, "returncode": pr.returncode}
+    except Exception as e:
+        traceback.print_exc()
+        SYNC_STATE.update({"running": False, "last_ok": False,
+                           "last_end": datetime.now().isoformat(timespec="seconds"),
+                           "last_msg": str(e)[:500]})
+        return {"ok": False, "error": str(e)}
+    finally:
+        SYNC_LOCK.release()
+
+
+def _sync_thread(trigger):
+    def _run():
+        try:
+            run_sync(trigger)
+        except Exception:
+            traceback.print_exc()
+    threading.Thread(target=_run, daemon=True).start()
+
+
+def daily_sync_loop(log=print):
+    """每天 SYNC_HOUR:00 触发;启动时若距上次成功太久则补跑"""
+    # 启动补跑
+    try:
+        last = db.get_setting("last_sync")
+        stale = True
+        if last:
+            try:
+                stale = (datetime.now() - datetime.fromisoformat(last)).total_seconds() > SYNC_CATCHUP_HOURS * 3600
+            except ValueError:
+                stale = True
+        if stale:
+            time.sleep(90)          # 等服务和收件箱 watcher 起来
+            log("[sync] 距上次同步超过 %d 小时,启动补跑一次" % SYNC_CATCHUP_HOURS)
+            _sync_thread("catchup")
+    except Exception:
+        traceback.print_exc()
+
+    while True:
+        try:
+            now = datetime.now()
+            nxt = now.replace(hour=SYNC_HOUR, minute=0, second=0, microsecond=0)
+            if nxt <= now:
+                nxt = nxt + timedelta(days=1)
+            wait = (nxt - now).total_seconds()
+            log("[sync] 下次自动同步:%s" % nxt.strftime("%Y-%m-%d %H:%M"))
+            time.sleep(max(60, wait))
+            _sync_thread("schedule")
+            time.sleep(120)         # 留出执行时间,避免同分钟重复触发
+        except Exception:
+            traceback.print_exc()
+            time.sleep(300)
+
+
+# ----------------------------------------------------------------------------
+# 收件箱自动导入
+# ----------------------------------------------------------------------------
+def watch_inbox(interval=20, log=print):
+    """data/inbox 里丢 CSV / Excel 就自动导入,处理完移到 data/processed"""
+    while True:
+        try:
+            files = [f for f in os.listdir(db.INBOX)
+                     if f.lower().endswith((".csv", ".xlsx")) and not f.startswith("~$")]
+            for f in files:
+                path = os.path.join(db.INBOX, f)
+                try:
+                    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                    dest = "%s_%s" % (stamp, f)
+                    r = ingest.import_file(path, file_path=dest)
+                    shutil.move(path, os.path.join(db.PROCESSED, dest))
+                    log("[inbox] %s → 导入 %d 行(%s)" % (f, r.get("rows_written", 0), r.get("period")))
+                except Exception as e:
+                    log("[inbox] %s 导入失败: %s" % (f, e))
+                    shutil.move(path, os.path.join(db.PROCESSED, "失败_" + f))
+        except Exception:
+            traceback.print_exc()
+        time.sleep(interval)
+
+
+# ----------------------------------------------------------------------------
+# HTTP
+# ----------------------------------------------------------------------------
+# ---------------------------------------------------------------- 权限
+# 路由 → 需要的权限点。没列出的 /api/* 一律只要登录(view)
+PERM_RULES = [
+    ("POST",   "/api/login",            None),      # 公开
+    ("POST",   "/api/logout",           None),
+    ("GET",    "/api/auth/state",       None),
+    ("GET",    "/api/me",               "view"),
+    ("GET",    "/api/users",            "users"),
+    ("POST",   "/api/users",            "users"),
+    ("PUT",    "/api/users",            "users"),
+    ("DELETE", "/api/users",            "users"),
+    ("GET",    "/api/export.file",      "export"),
+    ("GET",    "/api/export.csv",       "export"),
+    ("GET",    "/api/batch/",           "export"),
+    ("POST",   "/api/import",           "import"),
+    ("POST",   "/api/sync",             "sync"),
+    ("PUT",    "/api/thresholds",       "config"),
+    ("POST",   "/api/thresholds/reset", "config"),
+    ("PUT",    "/api/copytext",         "config"),
+    ("POST",   "/api/copytext/reset",   "config"),
+]
+SESSION_DAYS = 30
+
+
+def required_perm(method, path):
+    for m, pref, perm in PERM_RULES:
+        if m == method and (path == pref or path.startswith(pref)):
+            return perm, True
+    return "view", False
+
+
+def ensure_admin():
+    """首次运行创建初始管理员"""
+    if db.user_count() > 0:
+        return None
+    db.create_user("admin", "admin123", "admin", "管理员")
+    u = db.get_user_by_name("admin")
+    db.set_password(u["id"], "admin123", must_change=1)
+    return "admin123"
+
+
+class Handler(BaseHTTPRequestHandler):
+    server_version = "StoreOps/1.0"
+
+    def log_message(self, fmt, *args):
+        sys.stdout.write("  %s %s\n" % (self.command, self.path))
+
+    # ---- helpers ----
+    def _send(self, code, body, ctype="application/json; charset=utf-8", extra=None):
+        self.send_response(code)
+        self.send_header("Content-Type", ctype)
+        self.send_header("Content-Length", str(len(body)))
+        self.send_header("Cache-Control", "no-store")
+        for k, v in (extra or {}).items():
+            self.send_header(k, v)
+        self.end_headers()
+        self.wfile.write(body)
+
+    def _json(self, obj, code=200):
+        self._send(code, json.dumps(obj, ensure_ascii=False, default=str).encode("utf-8"))
+
+    def _err(self, msg, code=400):
+        self._json({"ok": False, "error": msg}, code)
+
+    def _cookies(self):
+        out = {}
+        for part in (self.headers.get("Cookie") or "").split(";"):
+            if "=" in part:
+                k, v = part.split("=", 1)
+                out[k.strip()] = v.strip()
+        return out
+
+    def _auth(self):
+        self.user = None
+        self.sid = None
+        sid = self._cookies().get("sid")
+        if sid:
+            h = auth.hash_token(sid)
+            u, _ = db.get_session(h)
+            if u:
+                self.user, self.sid = u, h
+        return self.user
+
+    def _guard(self, method, path):
+        """返回 True 表示已放行;否则已经写好响应,调用方直接 return"""
+        need, matched = required_perm(method, path)
+        if need is None:
+            return True
+        if not self.user:
+            self._json({"ok": False, "error": "请先登录", "need_login": True}, 401)
+            return False
+        if not auth.can(self.user["role"], need):
+            self._json({"ok": False, "error": "当前账号(%s)没有这个权限" %
+                                          auth.role_label(self.user["role"]), "forbidden": need}, 403)
+            return False
+        return True
+
+    def _body(self):
+        n = int(self.headers.get("Content-Length") or 0)
+        return self.rfile.read(n) if n else b""
+
+    def do_DELETE(self):
+        try:
+            u = urllib.parse.urlparse(self.path)
+            self._auth()
+            if u.path.startswith("/api/") and not self._guard("DELETE", u.path):
+                return
+            if u.path.startswith("/api/users/"):
+                uid = int(u.path.rsplit("/", 1)[1])
+                return self._user_delete(uid)
+            return self._err("没有这个接口", 404)
+        except Exception as e:
+            traceback.print_exc()
+            self._err("服务端异常: %s" % e, 500)
+
+    # ---- 账号相关 ----
+    def _login(self):
+        try:
+            body = json.loads(self._body().decode("utf-8") or "{}")
+        except (ValueError, UnicodeDecodeError):
+            return self._err("请求格式不对")
+        name = (body.get("username") or "").strip()
+        pw = body.get("password") or ""
+        u = db.get_user_by_name(name)
+        if not u or not auth.verify_password(pw, u["pass_hash"], u["salt"]):
+            time.sleep(0.4)          # 拖慢暴力破解
+            return self._err("账号或密码不对", 401)
+        if not u["active"]:
+            return self._err("该账号已被停用,请联系管理员", 403)
+        token = auth.new_token()
+        exp = (datetime.now() + timedelta(days=SESSION_DAYS)).isoformat(timespec="seconds")
+        db.create_session(auth.hash_token(token), u["id"], exp,
+                          self.headers.get("X-Forwarded-For", "") or self.client_address[0])
+        db.touch_login(u["id"])
+        payload = json.dumps({"ok": True, "must_change": bool(u["must_change"]),
+                              "role": u["role"],
+                              "display_name": u["display_name"]}).encode()
+        return self._send(200, payload, "application/json; charset=utf-8", {
+            "Set-Cookie": "sid=%s; Path=/; Max-Age=%d; HttpOnly; SameSite=Lax"
+                          % (token, SESSION_DAYS * 86400)})
+
+    def _user_create(self):
+        try:
+            b = json.loads(self._body().decode("utf-8") or "{}")
+        except (ValueError, UnicodeDecodeError):
+            return self._err("请求格式不对")
+        name = (b.get("username") or "").strip()
+        pw = b.get("password") or ""
+        role = b.get("role") or "viewer"
+        if not name:
+            return self._err("用户名不能为空")
+        if db.get_user_by_name(name):
+            return self._err("这个用户名已存在", 409)
+        if role not in auth.ROLES:
+            return self._err("角色不对")
+        ok, msg = auth.check_strength(pw)
+        if not ok:
+            return self._err(msg)
+        uid = db.create_user(name, pw, role, (b.get("display_name") or name).strip())
+        return self._json({"ok": True, "id": uid})
+
+    def _me_update(self):
+        """改自己的密码"""
+        try:
+            b = json.loads(self._body().decode("utf-8") or "{}")
+        except (ValueError, UnicodeDecodeError):
+            return self._err("请求格式不对")
+        u = self.user
+        new = b.get("password") or ""
+        ok, msg = auth.check_strength(new)
+        if not ok:
+            return self._err(msg)
+        if not u["must_change"]:
+            old = b.get("old_password") or ""
+            if not auth.verify_password(old, u["pass_hash"], u["salt"]):
+                return self._err("原密码不对", 403)
+        db.set_password(u["id"], new, must_change=0)
+        return self._json({"ok": True})
+
+    def _user_update(self, uid):
+        try:
+            b = json.loads(self._body().decode("utf-8") or "{}")
+        except (ValueError, UnicodeDecodeError):
+            return self._err("请求格式不对")
+        u = db.get_user(uid)
+        if not u:
+            return self._err("账号不存在", 404)
+
+        role = b.get("role")
+        if role is not None and role not in auth.ROLES:
+            return self._err("角色不对")
+        # 不能把最后一个管理员降级
+        if role and role != "admin" and u["role"] == "admin" and db.admin_count() <= 1:
+            return self._err("至少要保留一个管理员")
+
+        active = b.get("active")
+        if active is not None and not active and u["username"] == "admin":
+            return self._err("admin 是内置账号,不能停用")
+        if active is not None and not active and u["role"] == "admin" and db.admin_count() <= 1:
+            return self._err("至少要保留一个启用的管理员")
+        if active is not None and not active and uid == self.user["id"]:
+            return self._err("不能停用自己正在用的账号")
+
+        db.update_user(uid, role=role, display_name=b.get("display_name"), active=active)
+
+        pw = b.get("password")
+        if pw:
+            ok, msg = auth.check_strength(pw)
+            if not ok:
+                return self._err(msg)
+            db.set_password(uid, pw, must_change=1 if b.get("force_change") else 0)
+            db.delete_user_sessions(uid)      # 改密码后强制重新登录
+        return self._json({"ok": True})
+
+    def _user_delete(self, uid):
+        if self.user["id"] == uid:
+            return self._err("不能删除自己正在用的账号")
+        u = db.get_user(uid)
+        if not u:
+            return self._err("账号不存在", 404)
+        if u["username"] == "admin":
+            return self._err("admin 是内置账号,不能删除")
+        if u["role"] == "admin" and db.admin_count() <= 1:
+            return self._err("至少要保留一个管理员")
+        db.delete_user(uid)
+        return self._json({"ok": True})
+
+    def _static(self, path):
+        if path in ("/", ""):
+            if not getattr(self, "user", None):
+                path = "/login.html"       # 没登录先看登录页
+            else:
+                path = "/index.html"
+        fp = os.path.normpath(os.path.join(FRONTEND, path.lstrip("/")))
+        if not fp.startswith(FRONTEND) or not os.path.isfile(fp):
+            self._send(404, b"Not Found", "text/plain; charset=utf-8")
+            return
+        ctype = mimetypes.guess_type(fp)[0] or "application/octet-stream"
+        with open(fp, "rb") as f:
+            data = f.read()
+        self._send(200, data, ctype)
+
+    # ---- routes ----
+    def do_GET(self):
+        try:
+            u = urllib.parse.urlparse(self.path)
+            path, q = u.path, urllib.parse.parse_qs(u.query)
+            self._auth()
+            if path.startswith("/api/"):
+                if not self._guard("GET", path):
+                    return
+            if path == "/api/auth/state":
+                n = db.user_count()
+                admin = db.get_user_by_name("admin")
+                return self._json({
+                    "ok": True, "needs_setup": n == 0,
+                    "default_admin": bool(admin and admin["must_change"]),
+                    "logged_in": bool(self.user),
+                })
+            if path == "/api/me":
+                u2 = self.user
+                return self._json({"ok": True, "user": {
+                    "id": u2["id"], "username": u2["username"], "role": u2["role"],
+                    "role_label": auth.role_label(u2["role"]),
+                    "display_name": u2["display_name"],
+                    "must_change": bool(u2["must_change"]),
+                    "perms": sorted(auth.GRANTS.get(u2["role"], set())),
+                }})
+            if path == "/api/users":
+                return self._json({"ok": True, "users": db.list_users(),
+                                   "roles": [{"key": k, **auth.ROLES[k]} for k in auth.ROLE_ORDER],
+                                   "perms": auth.PERMS,
+                                   "grants": {k: sorted(v) for k, v in auth.GRANTS.items()}})
+            if path == "/api/meta":
+                return self._json({
+                    "ok": True,
+                    "periods": db.get_periods(),
+                    "thresholds": db.get_thresholds(),
+                    "defaults": rules.DEFAULT_THRESHOLDS,
+                    "factory": codesync.get_factory(),
+                    "customized": db.get_thresholds() != codesync.get_factory(),
+                    "history": db.import_history(),
+                    "stats": db.stats(),
+                    "inbox": {"path": db.INBOX, "processed": db.PROCESSED},
+                })
+            if path == "/api/dashboard":
+                return self._json(build_dashboard(q.get("period", [None])[0]))
+            if path == "/api/sync/status":
+                return self._json({"ok": True, "state": SYNC_STATE,
+                                   "last_sync": db.get_setting("last_sync"),
+                                   "schedule": "每天 %02d:00" % SYNC_HOUR,
+                                   "script": SYNC_SCRIPT})
+            if path == "/api/history":
+                return self._json({"ok": True, "history": db.import_history(50)})
+            if path == "/api/copytext":
+                return self._json({"ok": True, "texts": db.get_texts(),
+                                   "defaults": rules.DEFAULT_TEXTS})
+            if path.startswith("/api/batch/") and path.endswith("/file"):
+                try:
+                    bid = int(path.split("/")[3])
+                except (ValueError, IndexError):
+                    return self._err("批次号不正确", 400)
+                return self._send_batch_file(db.get_batch(bid))
+            if path == "/api/export.file":
+                return self._send_batch_file(db.latest_batch_with_file(q.get("period", [None])[0]))
+            if path == "/api/export.csv":
+                body, period = build_export(q)
+                name = "门店数据_%s.csv" % (period or "全部")
+                return self._send(200, body, "text/csv; charset=utf-8",
+                                  {"Content-Disposition":
+                                   "attachment; filename*=UTF-8''%s" % urllib.parse.quote(name)})
+            return self._static(path)
+        except Exception as e:
+            traceback.print_exc()
+            self._err("服务端异常: %s" % e, 500)
+
+    def _send_batch_file(self, batch):
+        """原样返回某次导入的原始文件"""
+        if not batch or not batch.get("file_path"):
+            return self._err("这个批次没有留存原始文件", 404)
+        p = os.path.join(db.PROCESSED, batch["file_path"])
+        if not os.path.isfile(p):
+            return self._err("原始文件已不存在(可能被清理)", 404)
+        import re as _re
+        name = _re.sub(r"^\d{8}_\d{6}_", "", batch["file_path"])
+        ctype = ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+                 if name.lower().endswith(".xlsx") else "text/csv; charset=utf-8")
+        with open(p, "rb") as fp:
+            body = fp.read()
+        return self._send(200, body, ctype,
+                          {"Content-Disposition":
+                           "attachment; filename*=UTF-8''%s" % urllib.parse.quote(name)})
+
+    def do_PUT(self):
+        try:
+            u = urllib.parse.urlparse(self.path)
+            self._auth()
+            if u.path.startswith("/api/") and not self._guard("PUT", u.path):
+                return
+            if u.path == "/api/me":
+                return self._me_update()
+            if u.path.startswith("/api/users/"):
+                return self._user_update(int(u.path.rsplit("/", 1)[1]))
+            if u.path == "/api/thresholds":
+                body = json.loads(self._body().decode("utf-8"))
+                merged = db.get_thresholds()
+                merged.update(body)
+                errs = rules.validate(merged)
+                if errs:
+                    return self._err(";".join(errs), 422)
+                db.set_thresholds(merged)
+                ok, msg = codesync.write_defaults(merged)
+                return self._json({"ok": True, "thresholds": db.get_thresholds(),
+                                   "code_synced": ok, "code_msg": msg,
+                                   "factory": codesync.get_factory()})
+            if u.path == "/api/copytext":
+                body = json.loads(self._body().decode("utf-8"))
+                if not isinstance(body, dict):
+                    return self._err("文案格式不正确", 422)
+                merged = db.get_texts()
+                for k, v in body.items():
+                    if k in rules.DEFAULT_TEXTS:
+                        merged[k] = str(v if v is not None else "")
+                db.set_texts(merged)
+                return self._json({"ok": True, "texts": db.get_texts()})
+            self._err("未知接口", 404)
+        except Exception as e:
+            traceback.print_exc()
+            self._err("保存失败: %s" % e, 500)
+
+    def do_POST(self):
+        try:
+            u = urllib.parse.urlparse(self.path)
+            q = urllib.parse.parse_qs(u.query)
+            self._auth()
+            if u.path.startswith("/api/") and not self._guard("POST", u.path):
+                return
+            if u.path == "/api/login":
+                return self._login()
+            if u.path == "/api/logout":
+                if self.sid:
+                    db.delete_session(self.sid)
+                return self._send(200, json.dumps({"ok": True}).encode(), 
+                                  "application/json; charset=utf-8",
+                                  {"Set-Cookie": "sid=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"})
+            if u.path == "/api/users":
+                return self._user_create()
+            if u.path == "/api/import":
+                raw = self._body()
+                if not raw:
+                    return self._err("文件内容为空")
+                filename = q.get("filename", ["upload.csv"])[0]
+                period = q.get("period", [None])[0] or None
+                date = q.get("date", [None])[0] or None
+                stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+                dest = "%s_%s" % (stamp, filename)
+                try:
+                    os.makedirs(db.PROCESSED, exist_ok=True)
+                    with open(os.path.join(db.PROCESSED, dest), "wb") as fp:
+                        fp.write(raw)
+                except OSError:
+                    dest = None
+                result = ingest.import_bytes(raw, filename, period=period, date=date, file_path=dest)
+                result["ok"] = result.get("ok", False)
+                return self._json(result, 200 if result["ok"] else 422)
+            if u.path == "/api/sync":
+                if SYNC_STATE.get("running"):
+                    return self._json({"ok": False, "error": "已有同步任务在进行中"}, 409)
+                threading.Thread(target=run_sync, args=("manual",), daemon=True).start()
+                return self._json({"ok": True, "msg": "已开始同步,约 3-5 分钟"})
+            if u.path == "/api/thresholds/reset":
+                fac = codesync.get_factory()
+                db.set_thresholds(dict(fac))
+                ok, msg = codesync.write_defaults(fac)
+                return self._json({"ok": True, "thresholds": db.get_thresholds(),
+                                   "code_synced": ok, "code_msg": msg,
+                                   "factory": fac})
+            if u.path == "/api/copytext/reset":
+                return self._json({"ok": True, "texts": db.reset_texts()})
+            self._err("未知接口", 404)
+        except Exception as e:
+            traceback.print_exc()
+            self._err("导入失败: %s" % e, 500)
+
+
+def bonjour_name():
+    """取 Bonjour 名(这台机器 hostname 被设成了 IP,不能直接用)"""
+    try:
+        out = subprocess.run(["scutil", "--get", "LocalHostName"],
+                             capture_output=True, text=True, timeout=3).stdout.strip()
+        if out:
+            return out
+    except Exception:
+        pass
+    h = socket.gethostname().split(".")[0]
+    return None if _re_is_ip(h) else h
+
+
+def _re_is_ip(v):
+    import re as _r
+    return bool(_r.match(r"^\d{1,3}(\.\d{1,3}){3}$", v or ""))
+
+
+def lan_ips():
+    """列出本机可用的局域网 IPv4 地址(用于手机/其他电脑访问)"""
+    ips = []
+    try:
+        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+        s.connect(("8.8.8.8", 80))
+        ips.append(s.getsockname()[0])
+        s.close()
+    except Exception:
+        pass
+    try:
+        for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
+            ip = info[4][0]
+            if ip not in ips:
+                ips.append(ip)
+    except Exception:
+        pass
+    return [ip for ip in ips if not ip.startswith("127.")]
+
+
+def main():
+    port = 8787
+    args = sys.argv[1:]
+    if "--port" in args:
+        port = int(args[args.index("--port") + 1])
+    lan = "--local" not in args          # 默认开放给局域网,--local 只绑本机
+    host = "0.0.0.0" if lan else "127.0.0.1"
+    db.init_db()
+    init_pw = ensure_admin()
+    if init_pw:
+        print(" [首次启动] 已创建管理员账号 admin / %s —— 登录后请立刻修改密码" % init_pw)
+    threading.Thread(target=watch_inbox, args=(20, lambda m: print(m)), daemon=True).start()
+    # 自动同步依赖 ego-browser(要能操作已登录生意经的浏览器)。
+    # 服务器上没有它就别启这个线程,免得每天 01:00 报一次错。
+    can_sync = bool(_ego_bin()) and os.path.isfile(SYNC_SCRIPT)
+    if can_sync:
+        threading.Thread(target=daily_sync_loop, args=(lambda m: print(m),), daemon=True).start()
+    srv = ThreadingHTTPServer((host, port), Handler)
+    url = "http://127.0.0.1:%d" % port
+    print("=" * 62)
+    print(" 门店检测工作台已启动")
+    print(" 本机访问: %s" % url)
+    if lan:
+        ips = lan_ips()
+        if ips:
+            for ip in ips:
+                print(" 局域网:   http://%s:%d" % (ip, port))
+        else:
+            print(" 局域网:   未取到本机 IP,请在命令行执行 ipconfig 查看 IPv4 地址")
+        hn = bonjour_name()
+        if hn:
+            print(" 更好记的写法: http://%s.local:%d   (同一 WiFi 下的 Mac / iPhone 可用)" % (hn, port))
+        print(" 打不开时依次检查:① 两台设备是否同一 WiFi ② 本机是否休眠 ③ 系统设置→网络→防火墙")
+    else:
+        print(" 已限制为仅本机访问(--local)")
+    print(" 数据目录: %s" % db.DATA_DIR)
+    print(" 收件箱:   %s  (CSV/Excel 丢进去自动导入)" % db.INBOX)
+    if can_sync:
+        print(" 数据同步: 每天 %02d:00 自动拉取近30天数据(启动时距上次超过 %d 小时会补跑)"
+              % (SYNC_HOUR, SYNC_CATCHUP_HOURS))
+        print(" 立即同步: 左侧栏【同步数据】,或 POST /api/sync")
+    else:
+        print(" 数据同步: 未启用(这台机器上没有 ego-browser)")
+        print("           请用【导入数据】上传从生意经导出的 Excel")
+    print(" 停止服务: Ctrl + C")
+    print("=" * 62)
+    if "--open" in args:
+        threading.Timer(1.0, lambda: webbrowser.open(url)).start()
+    try:
+        srv.serve_forever()
+    except KeyboardInterrupt:
+        print("\n已停止")
+        srv.shutdown()
+
+
+if __name__ == "__main__":
+    main()

+ 196 - 0
store-ops/backend/xlsx.py

@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+"""
+最小化 .xlsx 读取器(Python 标准库,无需 openpyxl)
+
+xlsx 是一个 zip 包:
+  xl/sharedStrings.xml        共享字符串表
+  xl/styles.xml               样式(用于识别日期格式)
+  xl/workbook.xml + .rels     工作表清单
+  xl/worksheets/sheetN.xml    单元格数据
+
+只做读取,返回 rows: [[str, ...], ...](含表头),日期单元格转为 YYYY-MM-DD。
+"""
+import io
+import re
+import zipfile
+import xml.etree.ElementTree as ET
+from datetime import datetime, timedelta
+
+NS = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}"
+NS_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
+NS_PKG_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
+
+# Excel 内置日期格式编号
+BUILTIN_DATE_FMTS = {14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 30, 36, 45, 46, 47}
+EPOCH = datetime(1899, 12, 30)
+
+
+def _col_index(ref):
+    """'BC12' -> 列号 54(0-based)"""
+    letters = re.match(r"([A-Z]+)", ref or "")
+    if not letters:
+        return None
+    n = 0
+    for ch in letters.group(1):
+        n = n * 26 + (ord(ch) - 64)
+    return n - 1
+
+
+def _local(tag):
+    return tag.rsplit("}", 1)[-1]
+
+
+def _parse_styles(z):
+    """返回 numFmtId 集合中属于日期格式的 id 集合;以及 xf 索引 -> numFmtId 映射"""
+    date_fmt_ids = set(BUILTIN_DATE_FMTS)
+    xf_map = {}
+    if "xl/styles.xml" not in z.namelist():
+        return xf_map
+    root = ET.fromstring(z.read("xl/styles.xml"))
+    custom = {}  # id -> 是否日期
+    for nf in root.iter(NS + "numFmt"):
+        try:
+            fid = int(nf.get("numFmtId"))
+        except (TypeError, ValueError):
+            continue
+        code = nf.get("formatCode") or ""
+        # 排除纯数字带千分位等:含 y/m/d/h 才算日期
+        if re.search(r"[ymdhs]", code, re.I) and not re.match(r"^[\d,#.\s]+$", code):
+            custom[fid] = True
+            date_fmt_ids.add(fid)
+    cellXfs = root.find(NS + "cellXfs")
+    if cellXfs is not None:
+        for i, xf in enumerate(cellXfs.findall(NS + "xf")):
+            try:
+                xf_map[i] = int(xf.get("numFmtId") or 0)
+            except ValueError:
+                xf_map[i] = 0
+    # 标记自定义日期格式
+    for i, fid in xf_map.items():
+        if fid in custom:
+            xf_map[i] = -1  # 特殊标记:自定义日期
+    return xf_map
+
+
+def _is_date_style(xf_map, s_attr):
+    if not s_attr:
+        return False
+    try:
+        fid = xf_map.get(int(s_attr))
+    except ValueError:
+        return False
+    return fid in BUILTIN_DATE_FMTS or fid == -1
+
+
+def _serial_to_date(v):
+    try:
+        n = float(v)
+    except ValueError:
+        return None
+    if n <= 0 or n > 60000:
+        return None
+    d = EPOCH + timedelta(days=n)
+    if n % 1 == 0:
+        return d.strftime("%Y-%m-%d")
+    return d.strftime("%Y-%m-%d")  # 时间部分舍弃,日粒度只到天
+
+
+def _shared_strings(z):
+    if "xl/sharedStrings.xml" not in z.namelist():
+        return []
+    root = ET.fromstring(z.read("xl/sharedStrings.xml"))
+    out = []
+    for si in root.findall(NS + "si"):
+        # 富文本 run 与纯文本 t 都归并为拼接
+        out.append("".join(t.text or "" for t in si.iter(NS + "t")))
+    return out
+
+
+def _sheet_paths(z):
+    """按 workbook.xml 里的顺序返回各 sheet 的 zip 路径"""
+    names = z.namelist()
+    wb = ET.fromstring(z.read("xl/workbook.xml"))
+    rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels"))
+    rel_map = {r.get("Id"): r.get("Target") for r in rels.findall(NS_PKG_REL + "Relationship")}
+    out = []
+    for sh in wb.find(NS + "sheets").findall(NS + "sheet"):
+        rid = sh.get(NS_R + "id")
+        target = rel_map.get(rid, "")
+        target = target.lstrip("/")
+        if not target.startswith("xl/"):
+            target = "xl/" + target
+        if target in names:
+            out.append((sh.get("name") or "", target))
+    return out
+
+
+def _sheet_rows(z, sheet_path, sst, xf_map):
+    root = ET.fromstring(z.read(sheet_path))
+    rows = []
+    for row in root.iter(NS + "row"):
+        cells = {}
+        max_i = -1
+        for c in row.findall(NS + "c"):
+            ref = c.get("r") or ""
+            i = _col_index(ref)
+            t = c.get("t") or ""
+            s_attr = c.get("s")
+            val = None
+            if t == "inlineStr":
+                is_el = c.find(NS + "is")
+                if is_el is not None:
+                    val = "".join(x.text or "" for x in is_el.iter(NS + "t"))
+            else:
+                v_el = c.find(NS + "v")
+                raw = v_el.text if v_el is not None else None
+                if raw is not None:
+                    if t == "s":
+                        try:
+                            val = sst[int(raw)]
+                        except (ValueError, IndexError):
+                            val = raw
+                    elif t == "b":
+                        val = "1" if raw.strip() == "1" else "0"
+                    elif t == "str":
+                        val = raw
+                    else:
+                        # 数字或日期
+                        if _is_date_style(xf_map, s_attr):
+                            val = _serial_to_date(raw) or raw
+                        else:
+                            val = raw
+            if i is None:
+                i = max_i + 1
+            if val is not None:
+                # 4.0 -> 4,去掉多余的 .0
+                if re.match(r"^-?\d+\.0+$", val.strip()):
+                    val = val.strip().split(".")[0]
+                cells[i] = val
+                max_i = max(max_i, i)
+        if cells:
+            width = max(cells) + 1
+            rows.append([cells.get(i, "") for i in range(width)])
+    return rows
+
+
+def read_bytes(raw):
+    """返回 (rows, sheet_names)。取第一个非空工作表。"""
+    try:
+        z = zipfile.ZipFile(io.BytesIO(raw))
+    except zipfile.BadZipFile:
+        raise ValueError("不是有效的 xlsx 文件(可能是旧版 .xls,请另存为 .xlsx)")
+    sst = _shared_strings(z)
+    xf_map = _parse_styles(z)
+    sheets = _sheet_paths(z)
+    if not sheets:
+        raise ValueError("xlsx 中没有工作表")
+    for name, path in sheets:
+        rows = _sheet_rows(z, path, sst, xf_map)
+        if rows:
+            return rows, [n for n, _ in sheets]
+    return [], [n for n, _ in sheets]
+
+
+def read_file(path):
+    with open(path, "rb") as fp:
+        return read_bytes(fp.read())

+ 34 - 0
store-ops/deploy/store-ops.service

@@ -0,0 +1,34 @@
+# store-ops —— systemd 服务模板
+#
+# 安装:
+#   sudo cp deploy/store-ops.service /etc/systemd/system/
+#   把下面的 WorkingDirectory / ExecStart 改成你的实际路径
+#   sudo systemctl daemon-reload
+#   sudo systemctl enable --now store-ops
+#
+# 查看日志:sudo journalctl -u store-ops -f
+
+[Unit]
+Description=门店检测工作台 Store Ops
+After=network.target
+
+[Service]
+Type=simple
+# ↓↓↓ 改成实际路径 ↓↓↓
+WorkingDirectory=/opt/store-ops
+ExecStart=/bin/bash /opt/store-ops/start.sh
+
+# 以普通用户运行(不要用 root)
+User=storeops
+Group=storeops
+
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+# 只允许本机访问(前面挂 nginx 时用),要直接对外就把 --local 去掉
+Environment=PORT=8787
+
+[Install]
+WantedBy=multi-user.target

+ 1694 - 0
store-ops/frontend/app.js

@@ -0,0 +1,1694 @@
+/* 门店检测工作台 —— 前端逻辑
+   数据全部来自后端 API:/api/dashboard、/api/thresholds、/api/import、/api/export.csv
+   前端不做检测计算,只负责渲染,规则唯一来源在 backend/rules.py */
+'use strict';
+
+const PAGE_SIZE = 10;    // 每页条数
+const state = {
+  period: null, kpi: 'ALL', city: '', cat: '', score: '', q: '', sort: 'sev', order: 'desc',
+  page: 0, rows: [],
+  data: null, thresholds: null, defaults: null, texts: null, textDefaults: null,
+};
+
+const $ = (id) => document.getElementById(id);
+const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+const pct = (v, d = 0) => (v == null ? '-' : (v * 100).toFixed(d) + '%');
+const money = (v) => Math.round(v || 0).toLocaleString();
+const cnt = (v) => String(Math.round(v || 0));   // 计数类:不带千分位
+
+function toast(msg, ms) {
+  const t = $('toast');
+  t.textContent = msg;
+  t.classList.add('on');
+  clearTimeout(t._tm);
+  t._tm = setTimeout(() => t.classList.remove('on'), ms || 2400);
+}
+
+async function api(path, opt) {
+  const r = await fetch(path, opt);
+  const ct = r.headers.get('content-type') || '';
+  const body = ct.includes('json') ? await r.json() : await r.text();
+  if (r.status === 401) { location.replace('/login.html'); throw new Error('请先登录'); }
+  if (!r.ok) throw new Error((body && body.error) || ('请求失败 ' + r.status));
+  return body;
+}
+
+/* ---------------- 当前账号与权限 ---------------- */
+function can(perm) {
+  return !!(state.me && (state.me.perms || []).includes(perm));
+}
+
+/* 按权限显示/隐藏功能入口 */
+function applyPerms() {
+  const map = {
+    btnTh: 'config', btnText: 'config', btnImport: 'import',
+    btnSync: 'sync', btnExportCur: 'export', btnUsers: 'users',
+  };
+  Object.keys(map).forEach(id => {
+    const el = $(id);
+    if (el) el.style.display = can(map[id]) ? '' : 'none';
+  });
+  if (state.me) {
+    const nm = state.me.display_name || state.me.username;
+    if ($('whoName')) $('whoName').textContent = nm;
+    if ($('whoRole')) $('whoRole').textContent = state.me.role_label;
+    if ($('whoAvatar')) $('whoAvatar').textContent = nm.slice(0, 1);
+  }
+  if (state.me && state.me.must_change) showChangePw(true);
+}
+
+/* 强制/主动修改密码 */
+function showChangePw(forced) {
+  closePanels();
+  const old = $('pwWrap');
+  if (old) old.remove();
+  const wrap = document.createElement('div');
+  wrap.id = 'pwWrap';
+  wrap.className = 'cfwrap';
+  wrap.innerHTML =
+    '<div class="cfcard" style="width:min(430px,92vw)">' +
+      '<div class="cfhead"><b>' + (forced ? '请先修改初始密码' : '修改密码') + '</b>' +
+        (forced ? '' : '<span class="pvclose" id="pwX">&times;</span>') + '</div>' +
+      '<div class="cfbody">' +
+        (forced ? '<div class="cfnote" style="margin:0 0 12px">出于安全考虑,初始密码必须先改掉才能继续使用。</div>' : '') +
+        (forced ? '' : '<div class="cfrow"><span class="k">原密码</span><span class="v">' +
+          '<input class="pwinput" id="pwOld" type="password" autocomplete="current-password"></span></div>') +
+        '<div class="cfrow"><span class="k">新密码</span><span class="v">' +
+          '<input class="pwinput" id="pwNew" type="password" autocomplete="new-password"></span></div>' +
+        '<div class="cfrow"><span class="k">再输一次</span><span class="v">' +
+          '<input class="pwinput" id="pwNew2" type="password" autocomplete="new-password"></span></div>' +
+        '<div class="cfnote">至少 6 位,不能全是数字。</div>' +
+        '<div class="err" id="pwErr" style="display:none"></div>' +
+      '</div>' +
+      '<div class="cffoot">' +
+        (forced ? '' : '<button id="pwCancel">取消</button>') +
+        '<button class="primary" id="pwOk">确认修改</button>' +
+      '</div>' +
+    '</div>';
+  document.body.appendChild(wrap);
+  requestAnimationFrame(() => wrap.classList.add('on'));
+  if (!forced && $('pwX')) $('pwX').onclick = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
+  if ($('pwCancel')) $('pwCancel').onclick = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
+
+  $('pwOk').onclick = async () => {
+    const n1 = $('pwNew').value, n2 = $('pwNew2').value;
+    const box = $('pwErr');
+    const fail = m => { box.textContent = m; box.style.display = 'block'; };
+    if (n1 !== n2) return fail('两次输入的新密码不一样');
+    if (n1.length < 6) return fail('密码至少 6 位');
+    if (/^\d+$/.test(n1)) return fail('密码不能全是数字');
+    try {
+      await api('/api/me', { method: 'PUT', headers: {'Content-Type': 'application/json'},
+                             body: JSON.stringify({ password: n1,
+                               old_password: forced ? null : $('pwOld').value }) });
+      if (state.me) state.me.must_change = false;
+      wrap.classList.remove('on');
+      setTimeout(() => wrap.remove(), 200);
+      toast('密码已修改,下次请用新密码登录');
+    } catch (e) { fail(e.message); }
+  };
+}
+
+/* ---------------- 加载 ---------------- */
+async function load(period) {
+  const q = period ? '?period=' + encodeURIComponent(period) : '';
+  try {
+    const d = await api('/api/dashboard' + q);
+    state.data = d;
+    state.thresholds = d.thresholds;
+    state.defaults = d.defaults;
+    state.texts = d.texts;
+    state.textDefaults = d.text_defaults;
+    state.period = d.period;
+    (d.stores || []).forEach(st => decorateStore(st, d.texts));
+    setupPeriods(d.periods, d.period);
+    renderAll();
+  } catch (e) {
+    toast('加载失败:' + e.message, 4000);
+  }
+}
+
+function setupPeriods(list, current) {
+  const sel = $('period');
+  sel.innerHTML = (list || []).map(p => {
+    const tag = p.granularity === 'snapshot' ? (p.label || ('近' + (p.days || 30) + '天 · 截至 ' + p.period))
+      : p.granularity === 'daily' ? (p.period + ' · 日更') : (p.period + ' · 月度汇总');
+    return '<option value="' + p.period + '">' + tag + '(' + p.stores + '店)</option>';
+  }).join('') || '<option>暂无数据</option>';
+  if (current) sel.value = current;
+}
+
+function renderAll() {
+  const d = state.data;
+  const empty = !d || d.empty;
+  $('empty').hidden = !empty;
+  document.querySelectorAll('.kpis,.charts,.bar,.panel').forEach(el => el.style.display = empty ? 'none' : '');
+  $('ruleNote').textContent = d && d.customized
+    ? '规则:自定义考核指标(默认值来自《门店考核指标》)'
+    : '规则来源:《门店考核指标》官方基准线';
+  const cur = d && !d.empty && (d.periods || []).find(x => x.period === d.period);
+  $('dataNote').textContent = d && !d.empty
+    ? ((cur && cur.window_start)
+        ? (cur.window_start + ' ~ ' + cur.window_end + ' · ')
+        : (d.period + ' 周期 · '))
+      + d.stores.length + ' 家门店 · 导入批次 ' + d.stats.batches
+    : '';
+  $('btnTh').classList.toggle('mod', !!(d && d.customized));
+  if (empty) return;
+  renderKpis();
+  renderCharts();
+  renderFilters();
+  renderTable();
+}
+
+/* 后端只下发「模板键 + 变量」,这里按当前文案模板还原成文本。
+   好处:同一个问题不再被传 4 遍(problems/flags/primary/secondary/actions)。 */
+function decorateStore(s, X) {
+  const problems = (s.problems || []).map(p => {
+    const vars = p.x || {};
+    const tpl = X[p.p] !== undefined ? X[p.p] : '';
+    const atpl = X[p.a] !== undefined ? X[p.a] : '';
+    return {
+      key: p.k, tier: p.t, layer: p.l, level: p.v, cat: p.c,
+      tpl: p.p, atpl: p.a, vars,
+      text: fmtTpl(tpl, vars),
+      action: fmtTpl(atpl, vars).replace(/^[\s\u2705\u2714\u2611\ufe0f]+/, '').trim(),
+    };
+  });
+  s.problems = problems;
+  s.primary = problems.find(p => p.key === s.primary_key) || null;
+  s.flags = problems.map(p => [p.level, p.text, p.action, p.key]);
+  s.cats = Array.from(new Set(problems.map(p => p.cat)));
+  return s;
+}
+
+/* ---------------- KPI ---------------- */
+function renderKpis() {
+  $('kpis').innerHTML = state.data.kpis.map(k =>
+    '<div class="kpi' + (k.hot ? ' hot' : '') + (state.kpi === k.k ? ' on' : '') +
+    '" data-k="' + k.k + '"><div class="v">' + k.value + '<span class="u">家</span></div>' +
+    '<div class="l">' + esc(k.label) + '</div></div>'
+  ).join('');
+  document.querySelectorAll('.kpi').forEach(el => el.onclick = () => {
+    state.kpi = el.dataset.k; state.page = 0; renderKpis(); renderTable();
+  });
+}
+
+/* ---------------- 图表 ---------------- */
+let c1 = null, c2 = null, c3 = null;
+const GOLD_BAR = {
+  type: 'linear', x: 0, y: 0, x2: 1, y2: 0,
+  colorStops: [{ offset: 0, color: '#eec25c' }, { offset: 1, color: '#c8952c' }],
+};
+function renderCharts() {
+  const d = state.data, T = d.thresholds;
+  const ax = {
+    axisLine: { lineStyle: { color: '#ecdfc2' } },
+    axisLabel: { color: '#93876f', fontSize: 11 },
+    splitLine: { lineStyle: { color: '#f6efe0' } },
+  };
+  if (!c1) c1 = echarts.init($('c1'));
+  const cats = d.dist.cats;
+  c1.setOption({
+    grid: { left: 118, top: 8, bottom: 24, right: 30 }, tooltip: {},
+    xAxis: Object.assign({ type: 'value' }, ax),
+    yAxis: { type: 'category', data: cats.map(x => x[0]), axisLabel: { color: '#7f7359', fontSize: 11 } },
+    series: [{ type: 'bar', data: cats.map(x => x[1]), barWidth: 11, itemStyle: { color: GOLD_BAR, borderRadius: [0, 3, 3, 0] } }],
+  }, true);
+
+  const a = d.dist.amount;
+  const baseLabel = T.hx_amt_base >= 10000 ? '达标≥' + (T.hx_amt_base / 10000).toFixed(1) + '万' : '达标≥' + T.hx_amt_base;
+  $('c2title').textContent = '核销金额分布(基准 ' + T.hx_amt_base + ' 元)';
+  if (!c2) c2 = echarts.init($('c2'));
+  c2.setOption({
+    grid: { left: 44, top: 14, bottom: 40, right: 16 }, tooltip: {},
+    xAxis: Object.assign({ type: 'category', data: ['零核销', '0-1千', '1千-基准', baseLabel] }, ax),
+    yAxis: Object.assign({ type: 'value' }, ax),
+    series: [{
+      type: 'bar', barWidth: 26,
+      data: [
+        { value: a.zero, itemStyle: { color: '#b5563a' } },
+        { value: a.lt1k, itemStyle: { color: '#cd8a35' } },
+        { value: a.ltbase, itemStyle: { color: '#ddb45e' } },
+        { value: a.ok, itemStyle: { color: '#6f9d7f' } }],
+      itemStyle: { borderRadius: [3, 3, 0, 0] },
+      label: { show: true, position: 'top', color: '#93876f', fontSize: 11 },
+    }],
+  }, true);
+
+  if (!c3) c3 = echarts.init($('c3'));
+  const cities = d.dist.cities.slice().reverse();
+  c3.setOption({
+    grid: { left: 72, top: 8, bottom: 24, right: 30 }, tooltip: {},
+    xAxis: Object.assign({ type: 'value' }, ax),
+    yAxis: { type: 'category', data: cities.map(x => x[0]), axisLabel: { color: '#7f7359', fontSize: 11 } },
+    series: [{ type: 'bar', data: cities.map(x => x[1]), barWidth: 10, itemStyle: { color: '#e3c47f', borderRadius: [0, 3, 3, 0] } }],
+  }, true);
+}
+
+/* ---------------- 筛选 / 表格 ---------------- */
+function renderFilters() {
+  const stores = state.data.stores;
+  const citySel = $('fcity'), catSel = $('fcat');
+  const cities = [...new Set(stores.map(s => s.city).filter(Boolean))].sort();
+  const cats = [...new Set(stores.flatMap(s => s.cats))].sort();
+  citySel.innerHTML = '<option value="">全部城市</option>' + cities.map(c => '<option>' + esc(c) + '</option>').join('');
+  catSel.innerHTML = '<option value="">全部问题类型</option>' + cats.map(c => '<option>' + esc(c) + '</option>').join('');
+  citySel.value = state.city; catSel.value = state.cat;
+  const scSel = $('fscore');
+  if (scSel) scSel.value = state.score;
+}
+
+/* 评分筛选档位:闭区间按【】语义含端点(4.3 同时落在 4.0-4.3 与 4.3-4.5,4.5 同时落在 4.3-4.5 与 4.5以上) */
+function scoreInBand(v, band) {
+  if (v == null || !band) return true;
+  const x = Math.round(v * 10) / 10;
+  if (band === 'lt40') return x < 4.0;
+  if (band === '40_43') return x >= 4.0 && x <= 4.3;
+  if (band === '43_45') return x >= 4.3 && x <= 4.5;
+  if (band === 'gte45') return x >= 4.5;
+  return true;
+}
+
+function filtered() {
+  const d = state.data, T = d.thresholds;
+  let rows = d.stores.slice();
+  switch (state.kpi) {
+    case 'R': rows = rows.filter(s => s.flags.some(f => f[0][0] === 'R')); break;
+    case 'SCORE': rows = rows.filter(s => s.score != null && s.score < T.score_base); break;
+    case 'HX': rows = rows.filter(s => s.hx_rate != null && s.hx_rate < T.hx_rate_base); break;
+    case 'ZERO': rows = rows.filter(s => s.hx_cnt === 0); break;
+  }
+  if (state.city) rows = rows.filter(s => s.city === state.city);
+  if (state.cat) rows = rows.filter(s => s.cats.indexOf(state.cat) >= 0);
+  if (state.score) rows = rows.filter(s => s.score != null && scoreInBand(s.score, state.score));
+  if (state.q) {
+    const q = state.q.toLowerCase();
+    rows = rows.filter(s => s.name.toLowerCase().indexOf(q) >= 0 || s.id.indexOf(q) >= 0);
+  }
+  const s = state.sort;
+  const dir = state.order === 'asc' ? 1 : -1;
+  const val = (o) => {
+    if (s === 'amt') return o.hx_amt;
+    if (s === 'visit') return o.visit;
+    if (s === 'score') return o.score;
+    if (s === 'hx') return o.hx_rate;
+    return o.sev;
+  };
+  rows.sort((x, y) => {
+    const ax = val(x), ay = val(y);
+    if (ax == null && ay == null) return 0;
+    if (ax == null) return 1;          // 空值永远排最后,不随方向翻转
+    if (ay == null) return -1;
+    const r = (ax - ay) * dir;
+    if (r !== 0) return r;
+    return (x.hx_amt || 0) - (y.hx_amt || 0);   // 同值时按金额兜底
+  });
+  return rows;
+}
+
+function sevColorOf(sev) {
+  return sev >= 3 ? '#b5563a' : sev >= 2 ? '#cd8a35' : sev >= 1 ? '#c9a24a' : sev >= .6 ? '#dcc79a' : '#e3d9c2';
+}
+/* 上榜:从「XX榜第3名、YY榜第12名」里取最好名次 */
+function rankText(txt) {
+  const t = (txt || '').trim();
+  if (!t || t === '-') return '<span class="muted">—</span>';
+  const nums = [...t.matchAll(/第\s*(\d+)\s*名/g)].map(m => parseInt(m[1], 10));
+  if (!nums.length) return '<span class="warn-text">入围</span>';
+  const best = Math.min(...nums);
+  return '<span class="good-text">第' + best + '名</span>';
+}
+
+function rowHtml(s) {
+  const T = state.thresholds || {};
+  // 超线/未达标的值标红,方便一眼扫到
+  const warn = (bad, level) => bad ? ' style="color:' + (level === 'R' ? '#b5563a' : '#c8952c') + ';font-weight:600"' : '';
+  const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
+  const scoreBad = s.score != null && s.score > 0 && s.score < T.score_base;
+  const scoreUnrated = s.score === 0;
+  const tdBad = (s.td || 0) > (T.tuDan ? T.tuDan[0] : 9);
+  const tsBad = (s.ts || 0) > (T.touSu ? T.touSu[0] : 9);
+  const cpBad = s.cp != null && s.cp > (T.chaPing ? T.chaPing[0] : 9);
+  const replyBad = s.reply != null && s.reply < T.reply_base / 100;
+  const pendBad = pend >= (T.pend_high || 30);
+
+  return '<tr class="row" data-id="' + s.id + '"><td class="cl-name"><div class="nm"><span class="dot" style="background:' +
+    sevColorOf(s.sev) + '"></span>' + esc(s.name) + '</div><div class="sid">' + s.id + '</div></td>' +
+    '<td class="cl-city">' + esc(s.city) + '</td>' +
+    '<td class="num"' + warn(scoreBad, 'R') + '>' +
+      (scoreUnrated ? '<span class="muted">未开分</span>' : (s.score == null ? '-' : s.score.toFixed(1))) + '</td>' +
+    '<td class="num">' + money(s.visit) + '</td>' +
+    '<td class="num">' + money(s.deal) + '</td>' +
+    '<td class="num"' + warn(s.hx_rate != null && s.hx_rate < T.hx_rate_base, 'B') + '>' + pct(s.hx_rate) + '</td>' +
+    '<td class="num"' + warn((s.hx_amt || 0) < T.hx_amt_base, 'B') + '>' + money(s.hx_amt) + '</td>' +
+    '<td class="num"' + warn(pendBad, 'B') + '>' + money(pend) + '</td>' +
+    '<td class="num"' + warn(tdBad, 'R') + '>' + pct(s.td, 2) + '</td>' +
+    '<td class="num"' + warn(tsBad, 'R') + '>' + pct(s.ts, 2) + '</td>' +
+    '<td class="num"' + warn(cpBad, 'R') + '>' +
+      (s.cp == null ? '<span class="muted">无评价</span>' : pct(s.cp, 2)) + '</td>' +
+    '<td class="num"' + warn(replyBad, 'B') + '>' + pct(s.reply) + '</td>' +
+    '<td class="num cl-rank">' + rankText(s.rank) + '</td>' +
+    '<td class="cl-tags">' + s.problems.slice(0, 5).map(p =>
+      '<span class="tag t-' + p.level + '">' + esc(p.cat) + '</span>').join('') +
+    (s.problems.length > 5 ? '<span class="tag t-T">+' + (s.problems.length - 5) + '</span>' : '') +
+    '</td></tr>';
+}
+
+/* 列表:分页渲染,每页 PAGE_SIZE 条 */
+function renderTable() {
+  const rows = filtered();
+  state.rows = rows;
+  const pages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
+  if (state.page >= pages) state.page = pages - 1;
+  if (state.page < 0) state.page = 0;
+
+  const slice = rows.slice(state.page * PAGE_SIZE, state.page * PAGE_SIZE + PAGE_SIZE);
+  $('tb').innerHTML = slice.map(rowHtml).join('') ||
+    '<tr><td colspan="13" style="color:#a1957d;padding:30px;text-align:center">没有符合筛选条件的门店</td></tr>';
+  bindRows();
+
+  $('cnt').textContent = '共 ' + rows.length + ' 家';
+  $('pginfo').textContent = '第 ' + (state.page + 1) + ' / ' + pages + ' 页';
+  $('prev').disabled = state.page === 0;
+  $('next').disabled = state.page >= pages - 1;
+}
+
+function bindRows() {
+  document.querySelectorAll('tr.row').forEach(el => el.onclick = () => openDrawer(el.dataset.id));
+}
+
+/* ---------------- 门店详情 ---------------- */
+function metricsOf(s, T) {
+  return [
+    { k: '评分', v: s.score, fmt: v => v.toFixed(1), max: 5, base: T.score_base, target: T.score_target, dir: 'high' },
+    { k: '核销率', v: s.hx_rate, fmt: v => pct(v), max: 1, base: T.hx_rate_base, target: T.hx_rate_target, dir: 'high' },
+    { k: '核销金额', v: s.hx_amt, fmt: v => money(v) + '元', max: T.hx_amt_base * 2, base: T.hx_amt_base, target: T.hx_amt_base, dir: 'high' },
+    { k: '商责退单率', v: s.td, fmt: v => pct(v, 2), max: T.tuDan[2] * 1.1, warn: T.tuDan, dir: 'low' },
+    { k: '投诉率', v: s.ts, fmt: v => pct(v, 2), max: T.touSu[2] * 1.4, warn: T.touSu, dir: 'low' },
+    { k: '差评率', v: s.cp, fmt: v => v == null ? '无评价' : pct(v, 2), max: T.chaPing[2] * 1.4, warn: T.chaPing, dir: 'low' },
+    { k: '评价回复率', v: s.reply, fmt: v => pct(v), max: 1, base: T.reply_base / 100, target: 1, dir: 'high' },
+  ];
+}
+function barColor(m, v) {
+  if (v == null) return '#e3d9c2';
+  if (m.dir === 'high') return v < m.base ? '#b5563a' : v < m.target ? '#cd8a35' : '#6f9d7f';
+  return v > m.warn[2] ? '#b5563a' : v > m.warn[1] ? '#cd8a35' : v > m.warn[0] ? '#c9a24a' : '#6f9d7f';
+}
+
+function openDrawer(id) {
+  const s = state.data.stores.find(x => x.id === id);
+  if (!s) return;
+  const T = state.thresholds;
+  const D = diagnosisOf(s, state.texts);
+  closePanels();
+  let h = '<div class="dwhead"><h2>' + esc(s.name) + '</h2><div class="sid">' + s.id + ' · ' + esc(s.city) +
+    ' · 严重度 ' + s.sev.toFixed(1) + (s.days ? ' · ' + s.days + ' 天数据' : '') + '</div>' +
+    '<span class="dwclose" id="dwClose" title="关闭">&times;</span></div><div class="dwbody">';
+
+  // 一句话结论
+  h += '<div class="dxhead t-' + s.tier + '">' +
+    '<div class="dxhd">' + esc(D.headline) + '</div>' +
+    '<div class="dxmeta">共命中 ' + D.problems.length + ' 项 · 整改动作 ' + D.actions.length + ' 条</div></div>';
+
+  // 主因 + 次要问题
+  if (D.primary) {
+    h += '<div class="dxsec"><div class="dxt">主因</div>' +
+      '<div class="dxrow primary"><span class="tag t-' + D.primary.level + '">' + esc(D.primary.text) + '</span>' +
+      '<div class="dxact">' + esc(D.primary.action) + '</div></div>';
+    const rest = D.problems.filter(p => p !== D.primary);
+    if (rest.length) {
+      h += '<div class="dxt" style="margin-top:12px">其余问题(' + rest.length + ' 项)</div>' +
+        rest.map(p => '<div class="dxrow"><span class="tag t-' + p.level + '">' + esc(p.text) + '</span>' +
+          '<div class="dxact">' + esc(p.action) + '</div></div>').join('');
+    }
+    h += '</div>';
+  } else {
+    h += '<div class="dxsec"><div class="dxnone">' + esc(state.texts.fb_no_issue) + '</div></div>';
+  }
+
+  // 指标条
+  h += '<div class="dxt" style="margin-top:6px">关键指标</div>';
+  h += metricsOf(s, T).map(m => {
+    const v = typeof m.v === 'function' ? m.v(s) : m.v;
+    const w = v == null ? 0 : Math.min(v / m.max * 100, 100);
+    const marks = m.warn
+      ? m.warn.map(x => '<span class="mk" style="left:' + (x / m.max * 100).toFixed(1) + '%"></span>').join('')
+      : '<span class="mk" style="left:' + (m.base / m.max * 100).toFixed(1) + '%"></span>' +
+        '<span class="mk" style="left:' + (m.target / m.max * 100).toFixed(1) + '%;background:#6f9d7f"></span>';
+    return '<div class="mrow"><div class="lab"><span>' + m.k + '</span><span>' + (v == null ? '-' : m.fmt(v)) + '</span></div>' +
+      '<div class="track"><div class="fill" style="width:' + w.toFixed(1) + '%;background:' + barColor(m, v) + '"></div>' + marks + '</div></div>';
+  }).join('');
+  if (s.rank) {
+    h += '<div class="rankbox"><span class="rk">上榜</span>' + esc(s.rank) + '</div>';
+  }
+  h += '<div class="mrow" style="color:#93876f;font-size:11.5px">访问 ' + money(s.visit) + ' 次 · 发布视频 ' + s.videos +
+    ' 个 · 成交券 ' + money(s.deal) + ' 张 · 点亮 ' + (s.lit || 0) + ' 次</div>';
+
+
+  h += '<div class="dwbrow">' +
+    '<button class="copybtn" id="pvBtn">预览分析结果</button>' +
+    '<button class="rpentry" id="rpBtn">导出报告</button></div>' +
+    '<div class="hint">【预览分析结果】按文案模板生成发给督导的反馈,可直接改再复制;' +
+    '【导出报告】生成一页 A4 门店分析报告,可打印成 PDF 或下载 HTML 存档。</div></div>';
+  const dw = $('dw');
+  dw.innerHTML = h;
+  dw.classList.add('on');
+  $('ov').classList.add('on');
+  $('pvBtn').onclick = () => openPreview(s);
+  $('rpBtn').onclick = () => openReport(s);
+  $('dwClose').onclick = closePanels;
+}
+
+/* ---------------- 分析结果预览(可改,不保存) ---------------- */
+function ensurePreviewDom() {
+  if ($('pvWrap')) return;
+  const pv = document.createElement('div');
+  pv.id = 'pvWrap';
+  pv.className = 'pvwrap';
+  pv.innerHTML =
+    '<div class="pvcard"><div class="pvhead"><b>预览分析结果</b>' +
+    '<span class="pvclose" id="pvClose" title="关闭">&times;</span></div>' +
+    '<textarea id="pvText" spellcheck="false"></textarea>' +
+    '<div class="pvfoot"><button id="pvRegen">重新生成</button>' +
+    '<button class="primary" id="pvCopy">复制定稿文案</button></div></div>';
+  document.body.appendChild(pv);
+}
+function closePreview() {
+  const pv = $('pvWrap');
+  if (pv) pv.classList.remove('on');
+}
+function openPreview(s) {
+  ensurePreviewDom();
+  const pv = $('pvWrap');
+  $('pvText').value = feedbackText(s);
+  pv.classList.add('on');
+  $('pvClose').onclick = closePreview;
+  pv.onclick = e => { if (e.target === pv) closePreview(); };
+  $('pvRegen').onclick = () => { $('pvText').value = feedbackText(s); };
+  $('pvCopy').onclick = () => {
+    const t = $('pvText').value;
+    if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t);
+    else {
+      const ta = document.createElement('textarea');
+      ta.value = t; document.body.appendChild(ta); ta.select();
+      document.execCommand('copy'); ta.remove();
+    }
+    const b = $('pvCopy'); b.textContent = '已复制 ✓';
+    setTimeout(() => { b.textContent = '复制定稿文案'; }, 1600);
+  };
+}
+
+function fmtTpl(tpl, vars) {
+  return String(tpl == null ? '' : tpl).replace(/\{([^{}]+)\}/g, (m, k) =>
+    vars[k] !== undefined && vars[k] !== null ? vars[k] : m);
+}
+/* 变量别名:允许直接按数据表列名写(如 {门店意向成交券数}),与短名等价 */
+const VAR_ALIAS = {
+  '门店名称': '门店名', '门店城市': '城市', '统计周期': '周期',
+  '门店页访问次数': '访问', '门店页有效人数': '访问', '访问次数': '访问', '有效人数': '访问',
+  '门店页成交券数': '客资', '成交券数': '客资',
+  '门店意向成交券数': '客资', '意向成交券数': '客资', '意向成交': '客资',
+  '门店核销券数': '核销券数', '核销券': '核销券数',
+  '门店意向退款券数': '退单', '意向退款券数': '退单', '门店退款券数': '退单', '退款券数': '退单',
+  '门店实际成交人数': '成交', '成交人数': '成交', '实际成交': '成交',
+  '流失人数': '流失', '流失人数占比': '流失占比',
+  '核销金额': '金额', '核销金额(元)': '金额',
+  '门店评分': '评分', '团购评分': '评分', '评价分': '评分',
+  '待核销订单数': '待核销', '后台待核销订单': '待核销', '后台待核销订单数': '待核销',
+  '评价回复率': '回复率', '核销率基准': '核销目标', '核销率目标': '核销目标',
+  '评分基准线': '评分基准', '考核基准': '评分基准',
+};
+
+function fbVars(s) {
+  const T = state.thresholds;
+  const visit = s.visit || 0, deal = s.deal || 0;
+  const lost = Math.max(visit - deal, 0);
+  const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
+  const v = {
+    '门店名': s.name, '门店ID': s.id, '城市': s.city, '周期': state.period,
+    '访问': cnt(visit), '客资': cnt(s.deal), '核销': cnt(s.hx_cnt),
+    '退单': cnt(s.deal_refund), '待核销': cnt(pend),
+    '成交': cnt(deal), '流失': cnt(lost),
+    '成交占比': visit > 0 ? pct(deal / visit, 2) : '-',
+    '流失占比': visit > 0 ? pct(lost / visit, 2) : '-',
+    '访问环比': '-',  // 暂无上月数据,可在模板中删掉该变量
+    '金额': money(s.hx_amt),
+    '评分': s.score == null ? '-' : s.score.toFixed(1),
+    '核销率': pct(s.hx_rate), '核销目标': pct(T.hx_rate_target),
+    '回复率': pct(s.reply), '回复基准': T.reply_base + '%',
+    '评分基准': T.score_base, '核销金额基准': T.hx_amt_base,
+    '核销券数': cnt(s.hx_cnt), '成交券数': cnt(s.deal),
+  };
+  // 展开别名,让全称变量也能渲染
+  Object.keys(VAR_ALIAS).forEach(a => { if (v[VAR_ALIAS[a]] !== undefined) v[a] = v[VAR_ALIAS[a]]; });
+  return v;
+}
+
+/* —— 变量校验:非法的(写错的)变量名会被原样输出,这里提前抓出来 —— */
+const RESERVED_VARS = ['i', '问题', '建议', '洞察', 'v', 'base', 'target', '口径', '档',
+  'n', '金额', '客单价', '新增', '原因', 'p', '结论', '动作', '榜'];
+const BASE_VARS = ['门店名', '门店ID', '城市', '周期', '访问', '客资', '核销', '退单', '待核销',
+  '成交', '流失', '成交占比', '流失占比', '访问环比', '金额', '评分', '核销率', '核销目标',
+  '回复率', '回复基准', '评分基准', '核销金额基准', '核销券数', '成交券数'];
+function knownVarSet() {
+  const known = new Set(BASE_VARS.concat(RESERVED_VARS));
+  Object.keys(VAR_ALIAS).forEach(k => known.add(k));
+  if (prevStore) Object.keys(fbVars(prevStore)).forEach(k => known.add(k));
+  return known;
+}
+function unknownVars(text) {
+  const known = knownVarSet(), out = [];
+  String(text == null ? '' : text).replace(/\{([^{}]+)\}/g, (m, k) => {
+    if (!known.has(k) && out.indexOf(k) < 0) out.push(k);
+    return m;
+  });
+  return out;
+}
+/* ---------------- 诊断结果(v2) ----------------
+   规则与判定全部在后端 rules.py;前端只做两件事:
+   1) 用当前(可能还没保存的)文案模板重渲染问题文案与动作,供文案配置实时预览
+   2) 按「主因 + 次要 + ≤3 动作」组织成反馈文案 */
+const LAYER_CN = { traffic: '流量', convert: '转化', fulfill: '履约', repute: '口碑' };
+const LV_WEIGHT = { R3: 3, R2: 2, R1: 1, B: 0.6, T: 0.3, N: 0 };
+
+function renderProblem(p, X) {
+  if (!p) return '';
+  const vars = Object.assign({}, p.vars || {});
+  if (p.level && String(p.level).charAt(0) === 'R') vars['档'] = X['lv_' + p.level] || vars['档'];
+  const tpl = (p.tpl && X[p.tpl] !== undefined) ? X[p.tpl] : p.text;
+  return fmtTpl(tpl, vars);
+}
+function renderAction(p, X) {
+  if (!p) return '';
+  const tpl = (p.atpl && X[p.atpl] !== undefined) ? X[p.atpl] : p.action;
+  // 去掉行首的 ✅/✓,避免和「✅ {动作}」模板叠加出现「✅ ✅」
+  return fmtTpl(tpl, p.vars || {}).replace(/^[\s\u2705\u2714\u2611\ufe0f]+/, '').trim();
+}
+
+/* 用当前模板重新组织出:问题列表 / 主因 / 次要 / 动作 / 结论 */
+function diagnosisOf(s, X) {
+  const problems = (s.problems || []).map(p =>
+    Object.assign({}, p, { text: renderProblem(p, X), action: renderAction(p, X) }));
+  const pk = s.primary && s.primary.key;
+  let primary = problems.find(p => p.key === pk) || null;
+  // 主因 key 找不到时(理论上不会)退化为最高档位那条
+  if (!primary && problems.length) {
+    primary = problems.slice().sort((a, b) =>
+      (LV_WEIGHT[b.level] || 0) - (LV_WEIGHT[a.level] || 0))[0];
+  }
+  const secondary = problems
+    .filter(p => p !== primary && (p.tier === 'R' || p.tier === 'B'))
+    .sort((a, b) => (LV_WEIGHT[b.level] || 0) - (LV_WEIGHT[a.level] || 0))
+    .slice(0, 2);
+  // 每个问题都给对应动作:主因排第一,其余按原顺序
+  const ordered = primary ? [primary].concat(problems.filter(p => p !== primary)) : problems.slice();
+  const actions = [];
+  ordered.forEach(p => {
+    const act = renderAction(p, X);
+    if (act && actions.indexOf(act) < 0) actions.push(act);
+  });
+  // 优先用规则专属结论句,没有就退回该层通用句(与 rules.py 同一套回退规则)
+  let hdKey = primary ? 'hd_' + primary.key : 'hd_OK';
+  if (X[hdKey] === undefined) hdKey = primary ? (primary.tier === 'R' ? 'hd_R' : 'hd_' + primary.layer) : 'hd_OK';
+  if (X[hdKey] === undefined) hdKey = 'hd_OK';
+  const headline = fmtTpl(X[hdKey] !== undefined ? X[hdKey] : (s.headline || ''),
+    { 问题: primary ? primary.text : '' });
+  return {
+    problems, primary, secondary, ordered,
+    actions,
+    headline, hdKey,
+  };
+}
+
+/* 洞察条目:哪几条出现、按什么顺序,只在这里决定(反馈文案与单店报告共用) */
+function insightTexts(s, X, D) {
+  const vars = fbVars(s);
+  const T = state.thresholds;
+  const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
+  const visit = s.visit || 0;
+  const out = [];
+  if (s.hx_rate != null && s.hx_rate < T.hx_rate_base) out.push(fmtTpl(X.fb_insight_metric, vars));
+  if (s.score != null && s.score < T.score_base) out.push(fmtTpl(X.fb_insight_score, vars));
+  if (visit > 0) out.push(fmtTpl(X.fb_insight_flow, vars));
+  if (pend > 0) out.push(fmtTpl(X.fb_insight_order, vars));
+  if (s.reply != null && s.reply < T.reply_base / 100) out.push(fmtTpl(X.fb_insight_reply, vars));
+  if (D && D.problems.length) out.push(fmtTpl(X.fb_insight_priority, vars));
+  return out;
+}
+
+function renderFeedback(s, X) {
+  const vars = fbVars(s);
+  const D = diagnosisOf(s, X);
+  const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
+  const visit = s.visit || 0;
+
+  const L = [X.fb_sec_overview, fmtTpl(X.fb_line_overview, vars)];
+  if (!D.problems.length) {
+    L.push(X.fb_no_issue);
+  } else {
+    L.push(X.fb_sec_headline);
+    L.push(fmtTpl(X.fb_item_headline, Object.assign({}, vars, { 结论: D.headline })));
+    const risk = D.ordered;
+    if (risk.length) {
+      L.push(X.fb_sec_risk);
+      risk.forEach((p, i) => L.push(
+        fmtTpl(X.fb_item_risk, Object.assign({}, vars, { i: i + 1, '问题': p.text }))));
+    }
+  }
+  if (pend > 0) L.push(fmtTpl(X.fb_line_pending, vars));
+  if (visit > 0) {
+    L.push(X.fb_sec_loss);
+    L.push(fmtTpl(X.fb_item_visit, Object.assign({ i: 1 }, vars)));
+    L.push(fmtTpl(X.fb_item_deal, Object.assign({ i: 2 }, vars)));
+    L.push(fmtTpl(X.fb_item_lost, Object.assign({ i: 3 }, vars)));
+  }
+  const ins = insightTexts(s, X, D);
+  if (ins.length) {
+    L.push(X.fb_sec_insight);
+    ins.forEach((t, k) => L.push(fmtTpl(X.fb_item_insight, { i: k + 1, '洞察': t })));
+  }
+  if (D.actions.length) {
+    L.push(X.fb_sec_action);
+    D.actions.forEach(a => L.push(fmtTpl(X.fb_action_item, Object.assign({}, vars, { 动作: a }))));
+  }
+  return L.join('\n');
+}
+function feedbackText(s) {
+  return renderFeedback(s, state.texts);
+}
+
+/* ---------------- 单店分析报告(A4 可打印 / 可下载) ---------------- */
+const SEV_TIERS = [
+  { min: 3, name: '非常严重', cls: 's3' },
+  { min: 2, name: '一般严重', cls: 's2' },
+  { min: 1, name: '预警', cls: 's1' },
+  { min: 0.6, name: '低于考核基准', cls: 's1' },
+  { min: 1e-9, name: '有提升项', cls: 'n' },
+  { min: 0, name: '达标', cls: 's0' },
+];
+function sevOf(sev) {
+  return SEV_TIERS.find(t => sev >= t.min) || SEV_TIERS[SEV_TIERS.length - 1];
+}
+
+/* 报告里的指标条:与详情抽屉同一套 metricsOf / barColor,只是换成打印友好的类名 */
+function reportBar(m, s) {
+  const v = typeof m.v === 'function' ? m.v(s) : m.v;
+  const w = v == null ? 0 : Math.min(v / m.max * 100, 100);
+  const shown = v == null ? (m.k === '差评率' ? '无评价' : '-') : m.fmt(v);
+  const marks = m.warn
+    ? m.warn.map(x => '<span class="mk" style="left:' + (x / m.max * 100).toFixed(1) + '%"></span>').join('')
+    : '<span class="mk" style="left:' + (m.base / m.max * 100).toFixed(1) + '%"></span>' +
+      '<span class="mk t" style="left:' + (m.target / m.max * 100).toFixed(1) + '%"></span>';
+  return '<div class="rpbar"><div class="lab"><span>' + m.k + '</span><b>' + shown + '</b></div>' +
+    '<div class="track"><div class="fill" style="width:' + w.toFixed(1) + '%;background:' +
+    barColor(m, v) + '"></div>' + marks + '</div></div>';
+}
+
+function nowStamp() {
+  const d = new Date(), p = n => String(n).padStart(2, '0');
+  return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
+    ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
+}
+
+function reportDocHtml(s) {
+  const T = state.thresholds, X = state.texts;
+  const vars = fbVars(s);
+  const D = diagnosisOf(s, X);
+  const sev = sevOf(s.sev);
+  const pend = Math.max((s.deal || 0) - (s.hx_cnt || 0), 0);
+  const visit = s.visit || 0, deal = s.deal || 0;
+  const lost = Math.max(visit - deal, 0);
+  const stamp = nowStamp();
+
+  const cards = [
+    ['门店页成交券数', cnt(s.deal), '张'],
+    ['核销券数', cnt(s.hx_cnt), '张'],
+    ['核销金额', money(s.hx_amt), '元'],
+    ['意向退款券数', cnt(s.deal_refund), '张'],
+    ['待核销订单', cnt(pend), '单'],
+    ['门店页访问次数', money(visit), '次'],
+    ['实际成交人数', cnt(deal), '人'],
+    ['流失人数', cnt(lost), visit > 0 ? '(' + pct(lost / visit, 1) + ')' : ''],
+  ].map(c => '<div><div class="k">' + c[0] + '</div><div class="v">' + c[1] +
+    (c[2] ? '<small>' + c[2] + '</small>' : '') + '</div></div>').join('');
+
+  // 风险表:主因打头,其余为次要问题;不再把所有问题一次性列出
+  const riskList = D.ordered;
+  const riskRows = riskList.map((p, i) =>
+    '<tr><td class="no">' + (i + 1) + '</td>' +
+    '<td><span class="rptag ' + p.level + '">' + esc(p.text) + '</span>' +
+    (i === 0 ? '<span class="rpflag">主因</span>' : '') + '</td>' +
+    '<td class="act">' + esc(p.action) + '</td></tr>').join('');
+
+  const insights = insightTexts(s, X, D).map((t, i) =>
+    '<li><span class="idx">' + (i + 1) + '</span>' + esc(t) + '</li>').join('');
+
+  const actions = D.actions.map(a =>
+    '<li><span class="ic">✓</span>' + esc(a) + '</li>').join('');
+
+  return '<div class="rpdoc">' +
+    '<header class="rphead"><span class="rpbrand">怡呵美 · 水光会员店</span>' +
+      '<h1>门店检测分析报告</h1>' +
+      '<div class="rpmeta">' +
+        '<span>门店 <b>' + esc(s.name) + '</b></span>' +
+        '<span>门店ID <b>' + s.id + '</b></span>' +
+        '<span>城市 <b>' + esc(s.city || '-') + '</b></span>' +
+        '<span>统计周期 <b>' + state.period + '</b></span>' +
+        (s.days ? '<span>有效数据 <b>' + s.days + ' 天</b></span>' : '') +
+        '<span>生成时间 <b>' + stamp + '</b></span>' +
+      '</div></header>' +
+
+    '<div class="rpsum">' +
+      '<div class="rplead"><div class="k">综合严重度</div>' +
+        '<div class="v">' + s.sev.toFixed(1) + '<span class="u">/ 3.0</span></div>' +
+        '<span class="rpsev ' + sev.cls + '">' + sev.name + '</span></div>' +
+      '<div class="rpstats">' +
+        '<div><b>' + D.problems.length + '</b><span>命中问题</span></div>' +
+        '<div><b>' + (s.score == null ? '-' : s.score.toFixed(1)) + '</b><span>团购评分</span></div>' +
+        '<div><b>' + pct(s.hx_rate) + '</b><span>核销率</span></div>' +
+        '<div><b>' + money(s.hx_amt) + '</b><span>核销金额(元)</span></div>' +
+        '<div><b>' + cnt(pend) + '</b><span>待核销(单)</span></div>' +
+      '</div>' +
+    '</div>' +
+
+    '<h2><span class="n">01</span>关键指标</h2>' +
+    '<div class="rpmetrics">' + metricsOf(s, T).map(m => reportBar(m, s)).join('') + '</div>' +
+    '<div class="rplegend">刻度:<i></i>考核基准,<i class="t"></i>平台目标;' +
+      '退单率 / 投诉率 / 差评率为三级违规线(预警 · 一般严重 · 非常严重)</div>' +
+
+    '<h2><span class="n">02</span>问题诊断</h2>' +
+    '<div class="rpdx">' + esc(D.headline) + '</div>' +
+    (riskList.length
+      ? '<table class="rpflags"><thead><tr><th></th><th>问题</th><th>建议动作</th></tr></thead>' +
+        '<tbody>' + riskRows + '</tbody></table>'
+      : '<div class="rpmuted">' + esc(X.fb_no_issue) + '</div>') +
+
+    '<h2><span class="n">03</span>经营数据(近30天)</h2>' +
+    '<div class="rpgrid">' + cards + '</div>' +
+
+    (insights ? '<h2><span class="n">04</span>分析洞察</h2><ul class="rpol">' + insights + '</ul>' : '') +
+
+    (actions ? '<h2><span class="n">05</span>门店调整动作</h2><ul class="rpacts">' + actions + '</ul>' : '') +
+
+    '<div class="rpfoot">' +
+      '本报告由「门店检测工作台」于 ' + stamp + ' 自动生成,数据周期 ' + state.period + '。<br>' +
+      '口径:核销率 = 核销券数 ÷ 意向成交券数;差评率在新增评价 ≥10 时取新增口径,否则取累计口径;' +
+      '评分取周期内最近一次有值数据(含 0 分未开分)。考核基准值可在工作台【指标配置】中调整。' +
+    '</div>' +
+  '</div>';
+}
+
+function ensureReportDom() {
+  if ($('rpWrap')) return;
+  const w = document.createElement('div');
+  w.id = 'rpWrap';
+  w.className = 'rp';
+  w.classList.add('rpwrap');
+  document.body.appendChild(w);
+}
+
+function openReport(s) {
+  ensureReportDom();
+  const w = $('rpWrap');
+  w.innerHTML = '<div class="rpcard rp">' +
+    '<div class="rptoolbar">' +
+      '<div class="rpname">' + esc(s.name) +
+        '<span class="rpsub">' + state.period + ' · 单店分析报告</span></div>' +
+      '<button class="rpbtn" id="rpDl">下载 HTML</button>' +
+      '<button class="rpbtn primary" id="rpPrint">打印 / 另存为 PDF</button>' +
+      '<button class="rpbtn x" id="rpClose" title="关闭">&times;</button>' +
+    '</div>' + reportDocHtml(s) + '</div>';
+  w.classList.add('on');
+  document.body.classList.add('rp-on');
+  $('rpClose').onclick = closeReport;
+  $('rpPrint').onclick = () => window.print();
+  $('rpDl').onclick = () => downloadReport(s);
+  w.onclick = e => { if (e.target === w) closeReport(); };
+}
+
+function closeReport() {
+  const w = $('rpWrap');
+  if (w) w.classList.remove('on');
+  document.body.classList.remove('rp-on');
+}
+
+let reportCssCache = null;
+async function downloadReport(s) {
+  try {
+    if (!reportCssCache) reportCssCache = await (await fetch('/report.css')).text();
+  } catch (e) {
+    toast('读取报告样式失败:' + e.message, 3600);
+    return;
+  }
+  const file = '门店分析报告_' + String(s.name).replace(/[\\/:*?"<>|]/g, '_') + '_' + state.period + '.html';
+  const doc = '<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">' +
+    '<meta name="viewport" content="width=device-width,initial-scale=1">' +
+    '<title>' + esc('门店分析报告 · ' + s.name + ' · ' + state.period) + '</title>' +
+    '<style>' + reportCssCache + '</style>' +
+    '<style>body{margin:0;background:#f8f4ea;padding:24px 16px}' +
+    '.rpdoc{max-width:820px;margin:0 auto;background:#fff;border-radius:12px;' +
+    'box-shadow:0 8px 30px rgba(120,90,20,.12)}' +
+    '.phtop{max-width:820px;margin:0 auto 14px;text-align:right}' +
+    '.phtop button{font-family:inherit;font-size:12px;padding:8px 16px;border-radius:8px;cursor:pointer;border:0;' +
+    'background:linear-gradient(135deg,#eab94e,#d29a29 48%,#b57e15);color:#fff;font-weight:600}' +
+    '@media print{body{padding:0;background:#fff}.phtop{display:none}' +
+    '.rpdoc{max-width:none;box-shadow:none;border-radius:0}}' +
+    '</style></head><body>' +
+    '<div class="phtop"><button onclick="window.print()">打印 / 另存为 PDF</button></div>' +
+    '<div class="rp">' + reportDocHtml(s) + '</div>' +
+    '</body></html>';
+  const url = URL.createObjectURL(new Blob([doc], { type: 'text/html;charset=utf-8' }));
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = file;
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+  setTimeout(() => URL.revokeObjectURL(url), 4000);
+  toast('已下载:' + file, 3200);
+}
+
+/* ---------------- 考核指标面板 ---------------- */
+const SPEC = [
+  { g: '门店评分', hint: '低于基准即判「评分不达标」', items: [{ p: 'score_base', l: '考核基准', step: '0.1' }, { p: 'score_target', l: '目标值', step: '0.1' }] },
+  { g: '核销率', hint: '核销券数 ÷ 意向成交券数', pct: 1, items: [{ p: 'hx_rate_base', l: '考核基准', step: '1' }, { p: 'hx_rate_target', l: '目标值', step: '1' }] },
+  { g: '核销金额', hint: '单店周期核销金额,低于基准即提醒', items: [{ p: 'hx_amt_base', l: '考核基准(元)', step: '500' }] },
+  { g: '商责退单率', hint: '超出即计入违规,分三级预警', pct: 1, items: [{ p: 'tuDan.0', l: '预警线' }, { p: 'tuDan.1', l: '一般严重' }, { p: 'tuDan.2', l: '非常严重' }] },
+  { g: '投诉率', hint: '超出即计入违规,分三级预警', pct: 1, items: [{ p: 'touSu.0', l: '预警线' }, { p: 'touSu.1', l: '一般严重' }, { p: 'touSu.2', l: '非常严重' }] },
+  { g: '差评率', hint: '只要有评价就判定:新增评价≥10 用新增口径,否则用累计口径', pct: 1, items: [{ p: 'chaPing.0', l: '预警线' }, { p: 'chaPing.1', l: '一般严重' }, { p: 'chaPing.2', l: '非常严重' }] },
+  { g: '评价回复率', hint: '低于此比例判「回复率偏低」', unit: '%', items: [{ p: 'reply_base', l: '最低要求' }] },
+  { g: '访问量', hint: '低于此值判「访问过低」', items: [{ p: 'visit_low', l: '过低线(次)', step: '10' }] },
+  { g: '成交转化率', hint: '成交人数 ÷ 访问人数 低于此值,判「转化率低」(T 级提示)', unit: '%',
+    items: [{ p: 'cvr_low', l: '过低线', step: '0.5' }] },
+  { g: '未核销积压', hint: '估算未核销券达到此数量,提示集中邀约', unit: '张',
+    items: [{ p: 'pend_high', l: '积压提醒线', step: '5' }] },
+  { g: '差评率样本量', hint: '达到最小样本才按三级判定;不足时最高只判预警线', unit: '条',
+    items: [{ p: 'cp_min_new', l: '新增口径最小评价数' }, { p: 'cp_min_total', l: '累计口径最小评价数' }] },
+  { g: '内容与评价量', hint: '低于此数判「数量偏少」(T 级提升项)', unit: '条',
+    items: [{ p: 'video_low', l: '视频数下限' }, { p: 'lit_low', l: '点亮数下限' },
+            { p: 'new_review_low', l: '新增评价数下限' }] },
+  { g: '其他最小样本量', hint: '样本不够就不判「不达标」,避免一条数据定性', unit: '条',
+    items: [{ p: 'hx_min_deal', l: '核销率最小成交券数' }, { p: 'amt_min_coupons', l: '客单价最小核销券数' },
+            { p: 'reply_min_new', l: '回复率最小新增评价数' }] },
+];
+const getP = (o, path) => path.split('.').reduce((v, k) => (v == null ? null : v[k]), o);
+function setP(o, path, val) {
+  const ks = path.split('.');
+  let v = o;
+  for (let i = 0; i < ks.length - 1; i++) v = v[ks[i]];
+  v[ks[ks.length - 1]] = val;
+}
+const uiOf = (obj, path, isPct) => {
+  const raw = getP(obj, path);
+  return isPct ? +(raw * 100).toFixed(3) : raw;
+};
+const unitOf = g => g.unit ? g.unit : g.pct ? '%' : (g.g.indexOf('金额') >= 0 ? '元' : g.g.indexOf('访问') >= 0 ? '次' : '分');
+
+function buildThresholdPanel() {
+  const T = state.thresholds, D = state.defaults;
+  $('thPill').textContent = state.data.customized ? '已自定义' : '出厂值';
+  $('thBody').innerHTML = SPEC.map(g => {
+    const cls = g.items.length === 3 ? '' : (g.items.length === 2 ? ' one' : ' single');
+    return '<div class="thgroup"><div class="gt">' + g.g + '</div><div class="gh">' + g.hint + '</div>' +
+      '<div class="throw' + cls + '">' + g.items.map(it => {
+        const isPct = g.pct ? 1 : 0;
+        const chg = Math.abs(uiOf(T, it.p, isPct) - uiOf(D, it.p, isPct)) > 1e-9 ? ' chg' : '';
+        return '<div class="thfield' + chg + '"><label>' + it.l + '</label><div class="inp">' +
+          '<input type="number" step="' + (it.step || '0.01') + '" data-p="' + it.p + '" data-pct="' + isPct +
+          '" value="' + uiOf(T, it.p, isPct) + '"><span class="u">' + unitOf(g) + '</span></div></div>';
+      }).join('') + '</div></div>';
+  }).join('');
+  document.querySelectorAll('#thBody input').forEach(inp => {
+    inp.oninput = () => {
+      const def = uiOf(D, inp.dataset.p, inp.dataset.pct === '1');
+      const cur = parseFloat(inp.value);
+      inp.closest('.thfield').classList.toggle('chg', !isNaN(cur) && Math.abs(cur - def) > 1e-9);
+    };
+  });
+}
+
+function collectThresholds() {
+  const T = JSON.parse(JSON.stringify(state.thresholds));
+  let bad = null;
+  document.querySelectorAll('#thBody input').forEach(inp => {
+    const v = parseFloat(inp.value);
+    if (isNaN(v)) { bad = bad || '有输入框为空'; return; }
+    setP(T, inp.dataset.p, inp.dataset.pct === '1' ? v / 100 : v);
+  });
+  return { T, bad };
+}
+
+async function applyThresholds() {
+  const { T, bad } = collectThresholds();
+  if (bad) { toast(bad); return; }
+  try {
+    const r = await api('/api/thresholds', {
+      method: 'PUT', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(T),
+    });
+    closePanels();
+    await load(state.period);
+    toast(r && r.code_synced ? '已生效,并同步写回代码默认值' :
+          ('已生效(写回代码失败:' + ((r && r.code_msg) || '未知') + ')'),
+          r && r.code_synced ? 2600 : 5000);
+  } catch (e) { toast('保存失败:' + e.message, 3600); }
+}
+
+async function resetThresholds() {
+  try {
+    const r = await api('/api/thresholds/reset', { method: 'POST' });
+    closePanels();
+    await load(state.period);
+    toast(r && r.code_synced ? '已恢复到出厂值,并同步写回代码' : '已恢复到出厂值', 3200);
+  } catch (e) { toast('恢复失败:' + e.message, 3600); }
+}
+
+/* ---------------- 文案配置面板 ---------------- */
+const TSPEC = [
+  { g: '概览与结论', hint: '点字段右侧「+ 变量」可直接插入。短名:{客资} {核销} {退单} {待核销} {成交} {流失} {成交占比} {流失占比} {访问} {金额} {评分} {核销率} {核销目标} {回复率} {核销金额基准} {门店名} {城市}。也支持按数据表列名写:{门店意向成交券数} {门店核销券数} {门店意向退款券数} {门店页有效人数}',
+    items: [
+      { p: 'fb_sec_overview', l: '概览段标题' },
+      { p: 'fb_line_overview', l: '概览行模板' },
+      { p: 'fb_sec_headline', l: '结论段标题' },
+      { p: 'fb_item_headline', l: '结论行模板({结论}=下面 hd_* 生成的整句)' },
+    ] },
+  { g: '一句话结论(按主因自动选一条)', hint: '诊断树先判定这家店卡在哪一层,再套用对应的一句。{问题}=该店具体问题文案',
+    items: [
+      { p: 'hd_R', l: '有违规在限流' },
+      { p: 'hd_traffic', l: '卡在流量层' },
+      { p: 'hd_convert', l: '卡在转化层' },
+      { p: 'hd_fulfill', l: '卡在履约层' },
+      { p: 'hd_repute', l: '卡在口碑层' },
+      { p: 'hd_AMT', l: '卡在客单价(核销率达标)' },
+      { p: 'hd_SCORE0', l: '未开分' },
+      { p: 'hd_OK', l: '全部达标' },
+    ] },
+  { g: '风险因素', hint: '只列主因 + 最多 2 条次要问题,不再把所有问题一次性堆出来',
+    items: [
+      { p: 'fb_sec_risk', l: '风险段标题' },
+      { p: 'fb_item_risk', l: '风险条目格式({i}=序号 {问题}=问题文案)' },
+      { p: 'fb_line_pending', l: '待核销订单行(待核销=0 时不显示)' },
+      { p: 'fb_no_issue', l: '无问题门店文案' },
+    ] },
+  { g: '顾客流失分析', hint: '有访问数据时显示这一段', items: [
+      { p: 'fb_sec_loss', l: '段标题' },
+      { p: 'fb_item_visit', l: '访问行' },
+      { p: 'fb_item_deal', l: '成交行' },
+      { p: 'fb_item_lost', l: '流失行' },
+    ] },
+  { g: '分析洞察', hint: '每条洞察按条件自动取舍:指标层面(核销率不达标)、口碑分(评分不达标)、流量转化(有访问)、订单风险(待核销>0)、口碑管理(回复率偏低)、整改优先级(有风险项)',
+    items: [
+      { p: 'fb_sec_insight', l: '段标题' },
+      { p: 'fb_item_insight', l: '洞察条目格式({i}=序号 {洞察}=洞察文案)' },
+      { p: 'fb_insight_metric', l: '指标层面' },
+      { p: 'fb_insight_score', l: '口碑分' },
+      { p: 'fb_insight_flow', l: '流量转化' },
+      { p: 'fb_insight_order', l: '订单风险' },
+      { p: 'fb_insight_reply', l: '口碑管理' },
+      { p: 'fb_insight_priority', l: '整改优先级' },
+    ] },
+  { g: '门店调整动作', hint: '动作来自诊断结果,只给主因和次要问题对应的动作,最多 3 条(不再给固定清单)',
+    items: [
+      { p: 'fb_sec_action', l: '段标题' },
+      { p: 'fb_action_item', l: '动作条目格式({动作}=该条整改动作)' },
+    ] },
+  { g: '严重档位名称', hint: '问题文案中 {档} 的显示名', items: [
+      { p: 'lv_R1', l: '一级' }, { p: 'lv_R2', l: '二级' }, { p: 'lv_R3', l: '三级' },
+    ] },
+  { g: '问题文案', hint: '变量:{v}=实际值 {base}=基准值 {口径}=新增/累计 {档}=档位名 {n}=券数 {金额}=核销金额 {客单价}=元 {新增}=新增评价数 {p}=积压券数 {原因}=未开分原因。显示在风险因素、详情抽屉和导出清单', items: [
+      { p: 't_TD', l: '商责退单率超线' },
+      { p: 't_TS', l: '投诉率超线' },
+      { p: 't_CP', l: '差评率超线' },
+      { p: 't_SCORE', l: '评分不达标' },
+      { p: 't_SCORE0', l: '未开分' },
+      { p: 't_REPLY', l: '评价回复率偏低' },
+      { p: 't_DEAL0', l: '零成交' },
+      { p: 't_ZERO', l: '成交未核销' },
+      { p: 't_HX', l: '核销率不达标' },
+      { p: 't_AMT', l: '核销金额偏低' },
+      { p: 't_CVR', l: '转化率低' },
+      { p: 't_VISIT', l: '访问过低' },
+      { p: 't_RVOL', l: '新增评价少' },
+      { p: 't_RANK0', l: '未上榜' },
+      { p: 't_RANK_IN', l: '仅入围({榜})'},
+      { p: 't_PEND', l: '未核销积压' },
+      { p: 't_LIT', l: '点亮偏低' },
+      { p: 't_VIDEO', l: '视频量少' },
+    ] },
+  { g: '整改建议', hint: '评分建议可用 {base}=基准 {target}=目标。一条问题对应一条建议,最终只输出 ≤3 条', items: [
+      { p: 'a_TD', l: '退单率超线' },
+      { p: 'a_TS', l: '投诉率超线' },
+      { p: 'a_CP', l: '差评率超线' },
+      { p: 'a_SCORE', l: '评分不达标' },
+      { p: 'a_SCORE0', l: '未开分' },
+      { p: 'a_REPLY', l: '评价回复率偏低' },
+      { p: 'a_DEAL0', l: '零成交' },
+      { p: 'a_ZERO', l: '成交未核销' },
+      { p: 'a_HX', l: '核销率不达标' },
+      { p: 'a_AMT', l: '核销金额偏低' },
+      { p: 'a_CVR', l: '转化率低' },
+      { p: 'a_VISIT', l: '访问过低' },
+      { p: 'a_RVOL', l: '新增评价少' },
+      { p: 'a_RANK0', l: '未上榜' },
+      { p: 'a_RANK_IN', l: '仅入围' },
+      { p: 'a_PEND', l: '未核销积压' },
+      { p: 'a_LIT', l: '点亮偏低' },
+      { p: 'a_VIDEO', l: '视频量少' },
+    ] },
+];
+
+function textsChanged() {
+  const X = state.texts, D = state.textDefaults;
+  return TSPEC.some(g => g.items.some(it => (X[it.p] || '') !== (D[it.p] || '')));
+}
+
+/* —— 实时预览 ——
+   v2 起不再在前端复刻一套判定规则:问题的判定结果由后端给出(s.problems),
+   这里只用「当前编辑中的文案」重渲染文案,规则唯一源仍是 backend/rules.py */
+let prevStore = null;
+function collectTextInputs() {
+  const X = Object.assign({}, state.texts);
+  document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => { X[inp.dataset.p] = inp.value; });
+  return X;
+}
+function renderTextPreview() {
+  if (!prevStore || !$('txPrev')) return;
+  const X = collectTextInputs();
+  const txt = renderFeedback(prevStore, X);
+  // 已知变量已替换,残留的 {...} 就是写错的,标红提示
+  $('txPrev').innerHTML = esc(txt).replace(/\{([^{}]+)\}/g,
+    '<span class="badvar">{$1}</span>');
+  const bad = [];
+  Object.keys(X).forEach(k => unknownVars(X[k]).forEach(u => { if (bad.indexOf(u) < 0) bad.push(u); }));
+  const tip = $('txBad');
+  if (tip) {
+    tip.style.display = bad.length ? 'block' : 'none';
+    tip.textContent = bad.length
+      ? '⚠️ 有 ' + bad.length + ' 个变量系统不认识,会原样输出:' + bad.map(u => '{' + u + '}').join(' ') +
+        '。请改用下方「可用变量」里的名字。'
+      : '';
+  }
+}
+
+const INSERT_VARS = [
+  '门店名', '门店ID', '城市', '周期',
+  '访问', '客资', '核销', '退单', '待核销', '成交', '流失', '成交占比', '流失占比',
+  '金额', '评分', '评分基准', '核销率', '核销目标', '回复率', '回复基准', '核销金额基准',
+];
+
+function insertAtCursor(inp, text) {
+  const st = inp.selectionStart == null ? inp.value.length : inp.selectionStart;
+  const en = inp.selectionEnd == null ? inp.value.length : inp.selectionEnd;
+  inp.value = inp.value.slice(0, st) + text + inp.value.slice(en);
+  if (inp.setSelectionRange) inp.setSelectionRange(st + text.length, st + text.length);
+  if (inp.focus) inp.focus();
+}
+
+function syncTextField(inp) {
+  const field = inp.closest('.thfield');
+  if (!field) return;
+  const def = state.textDefaults[inp.dataset.p] || '';
+  field.classList.toggle('chg', inp.value !== def);
+  const bad = unknownVars(inp.value);
+  field.classList.toggle('bad', bad.length > 0);
+  const w = field.querySelector('.varwarn');
+  if (w) w.textContent = bad.length
+    ? '⚠️ 无法识别的变量:' + bad.map(u => '{' + u + '}').join(' ') + '(会原样输出)'
+    : '';
+  renderTextPreview();
+}
+
+function buildTextPanel() {
+  const X = state.texts, D = state.textDefaults;
+  $('txPill').textContent = textsChanged() ? '自定义' : '默认值';
+  const chips = INSERT_VARS.map(v => '<b data-v="{' + v + '}">' + v + '</b>').join('');
+  $('txBody').innerHTML =
+    '<div class="thgroup"><div class="gt">实时预览</div>' +
+    '<div class="gh">以问题最多的门店「' + esc(prevStore.name) + '」为例,边改边看效果,点【应用到工作台】后保存</div>' +
+    '<div class="txbad" id="txBad"></div>' +
+    '<div class="txprev" id="txPrev"></div></div>' +
+    TSPEC.map(g =>
+    '<div class="thgroup"><div class="gt">' + g.g + '</div><div class="gh">' + esc(g.hint) + '</div>' +
+    '<div class="throw single">' + g.items.map(it => {
+      const chg = (X[it.p] || '') !== (D[it.p] || '') ? ' chg' : '';
+      const ctl = it.ta
+        ? '<textarea rows="7" data-p="' + it.p + '">' + esc(X[it.p]) + '</textarea>'
+        : '<input type="text" data-p="' + it.p + '" value="' + esc(X[it.p]) + '">';
+      return '<div class="thfield' + chg + '"><label>' + it.l +
+        '<button type="button" class="varbtn" data-for="' + it.p + '">+ 变量</button></label>' +
+        (it.ta ? ctl : '<div class="inp">' + ctl + '</div>') +
+        '<div class="varwarn"></div>' +
+        '<div class="varchips" data-for="' + it.p + '">' + chips + '</div></div>';
+    }).join('') + '</div></div>').join('');
+  document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => {
+    inp.oninput = () => syncTextField(inp);
+  });
+  document.querySelectorAll('#txBody .varbtn').forEach(btn => {
+    btn.onclick = () => {
+      const row = document.querySelector('#txBody .varchips[data-for="' + btn.dataset.for + '"]');
+      if (row) row.classList.toggle('on');
+    };
+  });
+  document.querySelectorAll('#txBody .varchips b').forEach(b => {
+    b.onclick = () => {
+      const p = b.parentNode.dataset.for;
+      const inp = document.querySelector('#txBody [data-p="' + p + '"]');
+      if (!inp) return;
+      insertAtCursor(inp, b.dataset.v);
+      syncTextField(inp);
+    };
+  });
+  document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => syncTextField(inp));
+}
+
+async function applyTexts() {
+  const X = {};
+  let empty = null;
+  document.querySelectorAll('#txBody input, #txBody textarea').forEach(inp => {
+    if (!inp.value.trim() && inp.dataset.p !== 'fb_actions') empty = empty || '「' + inp.dataset.p + '」不能为空';
+    X[inp.dataset.p] = inp.value;
+  });
+  if (empty) { toast(empty); return; }
+  const bad = [];
+  Object.keys(X).forEach(k => unknownVars(X[k]).forEach(u => { if (bad.indexOf(u) < 0) bad.push(u); }));
+  try {
+    await api('/api/copytext', {
+      method: 'PUT', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(X),
+    });
+    closePanels();
+    await load(state.period);
+    toast(bad.length
+      ? '已保存,但有 ' + bad.length + ' 个变量不认识(' + bad.map(u => '{' + u + '}').join(' ') + '),会原样输出'
+      : '文案已更新,全部门店生效', bad.length ? 4200 : 2400);
+  } catch (e) { toast('保存失败:' + e.message, 3600); }
+}
+
+async function resetTexts() {
+  try {
+    await api('/api/copytext/reset', { method: 'POST' });
+    closePanels();
+    await load(state.period);
+    toast('已恢复默认文案');
+  } catch (e) { toast('恢复失败:' + e.message, 3600); }
+}
+
+function openTexts() {
+  if (!state.data || state.data.empty) { toast('先导入数据再配置文案'); return; }
+  closePanels();
+  prevStore = state.data.stores.slice().sort((a, b) => b.flags.length - a.flags.length)[0] || state.data.stores[0];
+  buildTextPanel();
+  $('txPanel').classList.add('on');
+  $('btnText').classList.add('on');
+  $('ov').classList.add('on', 'hi');
+}
+
+/* ---------------- 导入 ---------------- */
+let pickedFile = null;
+
+function openImport() {
+  closePanels();
+  $('imPeriod').value = state.period || new Date().toISOString().slice(0, 7);
+  $('imDate').value = '';
+  $('imResult').innerHTML = '';
+  $('dropText').textContent = '点击选择 CSV / Excel 文件,或把文件拖到这里';
+  $('imGo').disabled = true;
+  pickedFile = null;
+  loadHistory();
+  $('imPanel').classList.add('on');
+  $('btnImport').classList.add('on');
+  $('ov').classList.add('on', 'hi');
+}
+
+function loadHistory() {
+  api('/api/meta').then(m => {
+    // 只对「当前数据库里还有数据」的周期给出可点的导出按钮
+    const have = new Set((m.periods || []).map(x => x.period));
+    $('imHistory').innerHTML = (m.history || []).map(h => {
+      const p = h.period || '';
+      const ok = p && have.has(p);
+      const hasFile = !!h.file_path;
+      return '<div class="hrow">' +
+        '<span class="hmain" title="' + esc(h.filename) + '"><b>' + esc(h.filename) + '</b> · ' +
+          h.rows_written + ' 行</span>' +
+        '<span class="hmeta">' + (p || '—') + ' · ' +
+          (h.created_at || '').slice(5, 16).replace('T', ' ') + '</span>' +
+        '<button class="hbtn" data-b="' + h.id + '"' + (hasFile ? '' : ' disabled') +
+          ' title="' + (hasFile ? '下载当时的原始文件(与生意经完全一致)'
+                                : '这个批次没有留存原始文件') + '">原始文件</button>' +
+        '</div>';
+    }).join('') || '<div style="color:#a1957d">还没有导入记录</div>';
+
+    document.querySelectorAll('#imHistory .hbtn').forEach(b => {
+      b.onclick = e => { e.stopPropagation(); downloadBatchFile(b.dataset.b); };
+    });
+  }).catch(() => {});
+}
+
+/* ---------------- 账号管理 ---------------- */
+function openUsers() {
+  closePanels();
+  $('usPanel').classList.add('on');
+  loadUsers();
+}
+
+async function loadUsers() {
+  try {
+    const d = await api('/api/users');
+    state.userRoles = d.roles;
+    state.usersCache = d.users;
+    const roleMap = {};
+    d.roles.forEach(r => roleMap[r.key] = r);
+    $('usList').innerHTML = (d.users || []).map(u => {
+      const opts = d.roles.map(r =>
+        '<option value="' + r.key + '"' + (r.key === u.role ? ' selected' : '') + '>' +
+        r.label + '</option>').join('');
+      return '<div class="urow' + (u.active ? '' : ' off') + '">' +
+        '<div class="uinfo"><b>' + esc(u.display_name || u.username) + '</b>' +
+          '<span class="uname">' + esc(u.username) + '</span>' +
+          (u.active ? '' : '<span class="uoff">已停用</span>') +
+          (u.id === (state.me && state.me.id) ? '<span class="ume">本人</span>' : '') +
+        '</div>' +
+        '<div class="uctl">' +
+          '<select class="urole" data-id="' + u.id + '">' + opts + '</select>' +
+          '<button class="ubtn" data-act="pw" data-id="' + u.id + '">重置密码</button>' +
+          (u.username === 'admin'
+            ? '<span class="ubadge">内置账号,不可停用/删除</span>'
+            : '<button class="ubtn" data-act="toggle" data-id="' + u.id + '">' +
+                (u.active ? '停用' : '启用') + '</button>' +
+              '<button class="ubtn danger" data-act="del" data-id="' + u.id + '">删除</button>') +
+        '</div>' +
+        '<div class="umeta">最近登录 ' + ((u.last_login || '').replace('T', ' ').slice(5, 16) || '从未') + '</div>' +
+        '</div>';
+    }).join('') || '<div style="color:#a1957d">还没有账号</div>';
+
+    document.querySelectorAll('#usList .urole').forEach(sel => {
+      sel.onchange = async () => {
+        try {
+          await api('/api/users/' + sel.dataset.id, { method: 'PUT',
+            headers: {'Content-Type': 'application/json'},
+            body: JSON.stringify({ role: sel.value }) });
+          toast('角色已修改');
+          loadUsers();
+        } catch (e) { toast(e.message, 3500); loadUsers(); }
+      };
+    });
+    document.querySelectorAll('#usList .ubtn').forEach(b => {
+      b.onclick = () => userAction(b.dataset.act, +b.dataset.id);
+    });
+  } catch (e) { toast('读取账号失败:' + e.message, 3500); }
+}
+
+async function userAction(act, id) {
+  try {
+    if (act === 'toggle') {
+      const u = (state.usersCache || []).find(x => x.id === id);
+      await api('/api/users/' + id, { method: 'PUT',
+        headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({ active: !(u && u.active) }) });
+      toast('已更新');
+      loadUsers();
+    } else if (act === 'del') {
+      if (!confirm('确定删除这个账号?删除后无法恢复。')) return;
+      await api('/api/users/' + id, { method: 'DELETE' });
+      toast('已删除');
+      loadUsers();
+    } else if (act === 'pw') {
+      const u = (state.usersCache || []).find(x => x.id === id);
+      resetPassword(id, (u && (u.display_name || u.username)) || '该账号');
+    }
+  } catch (e) { toast(e.message, 3500); }
+}
+
+/* 通用表单弹窗:一次性把所有字段列出来,不再一个个 prompt */
+function formModal(opts) {
+  const old = $('fmWrap');
+  if (old) old.remove();
+  const rows = opts.fields.map(f => {
+    const id = 'fm_' + f.key;
+    let input;
+    if (f.type === 'select') {
+      input = '<select class="pwinput" id="' + id + '">' + f.options.map(o =>
+        '<option value="' + o.value + '"' + (o.value === f.value ? ' selected' : '') + '>' +
+        o.label + '</option>').join('') + '</select>';
+    } else {
+      input = '<input class="pwinput" id="' + id + '" type="' + (f.type || 'text') + '"' +
+        (f.placeholder ? ' placeholder="' + esc(f.placeholder) + '"' : '') +
+        (f.value ? ' value="' + esc(f.value) + '"' : '') +
+        (f.autocomplete ? ' autocomplete="' + f.autocomplete + '"' : '') + '>';
+    }
+    return '<div class="fmfield"><label class="fmlabel">' + f.label +
+      (f.required ? '<span class="fmreq">*</span>' : '') + '</label>' + input +
+      (f.hint ? '<div class="fmhint">' + f.hint + '</div>' : '') + '</div>';
+  }).join('');
+
+  const wrap = document.createElement('div');
+  wrap.id = 'fmWrap';
+  wrap.className = 'cfwrap';
+  wrap.innerHTML =
+    '<div class="cfcard" style="width:min(440px,92vw)">' +
+      '<div class="cfhead"><b>' + opts.title + '</b><span class="pvclose" id="fmX">&times;</span></div>' +
+      '<div class="cfbody">' + rows +
+        '<div class="err" id="fmErr" style="display:none"></div>' +
+      '</div>' +
+      '<div class="cffoot"><button id="fmCancel">取消</button>' +
+        '<button class="primary" id="fmOk">' + (opts.okText || '确定') + '</button></div>' +
+    '</div>';
+  document.body.appendChild(wrap);
+  requestAnimationFrame(() => wrap.classList.add('on'));
+
+  const close = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
+  $('fmX').onclick = close;
+  $('fmCancel').onclick = close;
+  wrap.onclick = e => { if (e.target === wrap) close(); };
+  const first = $('fm_' + opts.fields[0].key);
+  if (first) setTimeout(() => first.focus(), 60);
+
+  const fail = m => { const b = $('fmErr'); b.textContent = m; b.style.display = 'block'; };
+  const submit = async () => {
+    const box = $('fmErr'); box.style.display = 'none';
+    const vals = {};
+    opts.fields.forEach(f => { vals[f.key] = ($('fm_' + f.key).value || '').trim(); });
+    const msg = opts.validate && opts.validate(vals);
+    if (msg) return fail(msg);
+    $('fmOk').disabled = true;
+    try {
+      await opts.onSubmit(vals);
+      close();
+    } catch (e) {
+      fail(e.message);
+      $('fmOk').disabled = false;
+    }
+  };
+  $('fmOk').onclick = submit;
+  wrap.querySelectorAll('input').forEach(inp => {
+    inp.onkeydown = e => { if (e.key === 'Enter') submit(); };
+  });
+}
+
+/* 新建账号:一次性填完 */
+function openUserNew() {
+  formModal({
+    title: '新建账号',
+    okText: '创建',
+    fields: [
+      { key: 'username', label: '登录账号', required: true,
+        placeholder: '英文或数字,登录时用', autocomplete: 'off',
+        hint: '只能包含字母、数字、下划线、点或减号' },
+      { key: 'display_name', label: '显示名称', placeholder: '如:督导·王(不填就用账号名)' },
+      { key: 'password', label: '初始密码', type: 'password', required: true,
+        autocomplete: 'new-password', hint: '至少 6 位,不能全是数字' },
+      { key: 'password2', label: '确认密码', type: 'password', required: true,
+        autocomplete: 'new-password' },
+      { key: 'role', label: '角色', type: 'select', value: 'viewer', options: [
+        { value: 'viewer', label: '查看 —— 只能看数据' },
+        { value: 'editor', label: '运营 —— 可导入、同步、导出' },
+        { value: 'admin',  label: '管理员 —— 全部权限,含账号管理' },
+      ] },
+    ],
+    validate: v => {
+      if (!v.username) return '请填写登录账号';
+      if (!/^[A-Za-z0-9_.-]{2,32}$/.test(v.username)) return '登录账号只能用字母、数字、_ . -,2~32 位';
+      if (v.password.length < 6) return '密码至少 6 位';
+      if (/^\d+$/.test(v.password)) return '密码不能全是数字';
+      if (v.password !== v.password2) return '两次输入的密码不一样';
+      return '';
+    },
+    onSubmit: async v => {
+      await api('/api/users', { method: 'POST', headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({ username: v.username, password: v.password,
+                               role: v.role, display_name: v.display_name }) });
+      toast('账号「' + (v.display_name || v.username) + '」已创建');
+      loadUsers();
+    },
+  });
+}
+
+/* 重置密码:同样是表单 */
+function resetPassword(id, name) {
+  formModal({
+    title: '重置「' + name + '」的密码',
+    okText: '重置',
+    fields: [
+      { key: 'password', label: '新密码', type: 'password', required: true,
+        autocomplete: 'new-password', hint: '至少 6 位,不能全是数字' },
+      { key: 'password2', label: '确认密码', type: 'password', required: true,
+        autocomplete: 'new-password' },
+    ],
+    validate: v => {
+      if (v.password.length < 6) return '密码至少 6 位';
+      if (/^\d+$/.test(v.password)) return '密码不能全是数字';
+      if (v.password !== v.password2) return '两次输入的密码不一样';
+      return '';
+    },
+    onSubmit: async v => {
+      await api('/api/users/' + id, { method: 'PUT', headers: {'Content-Type': 'application/json'},
+        body: JSON.stringify({ password: v.password, force_change: true }) });
+      toast('密码已重置,该账号下次登录需要修改');
+      loadUsers();
+    },
+  });
+}
+
+/* 下载某次导入的原始文件(生意经原样导出,不含任何计算) */
+function downloadBatchFile(batchId) {
+  if (!batchId) return;
+  window.location.href = '/api/batch/' + encodeURIComponent(batchId) + '/file';
+  toast('正在下载原始文件', 2400);
+}
+
+/* 左侧栏【导出原始数据】:当前周期最近一次导入的原件 */
+function downloadOriginal() {
+  if (!state.period) return;
+  window.location.href = '/api/export.file?period=' + encodeURIComponent(state.period);
+  toast('正在下载 ' + state.period + ' 的原始文件', 2600);
+}
+
+async function doImport() {
+  if (!pickedFile) return;
+  $('imGo').disabled = true;
+  $('imGo').textContent = '导入中…';
+  const params = new URLSearchParams({ filename: pickedFile.name });
+  if ($('imPeriod').value) params.set('period', $('imPeriod').value);
+  if ($('imDate').value) params.set('date', $('imDate').value);
+  try {
+    const r = await api('/api/import?' + params.toString(), {
+      method: 'POST', headers: { 'Content-Type': 'text/csv' }, body: pickedFile,
+    });
+    $('imResult').innerHTML =
+      '<div class="ok">✓ 导入成功:写入 <b>' + r.rows_written + '</b> 行,新增门店 <b>' + r.new_stores + '</b> 家<br>' +
+      '周期 ' + r.period + (r.date_range && r.date_range.length ? ' · 日期 ' + r.date_range[0] + ' ~ ' + r.date_range[1] : '') +
+      ' · 粒度 ' + (r.granularity === 'daily' ? '按日' : '周期汇总') +
+      (r.skipped ? '<br>跳过 ' + r.skipped + ' 行(' + (r.errors[0] || '空行') + ')' : '') + '</div>';
+    toast('导入完成,正在刷新');
+    await load(r.period);
+    loadHistory();
+    setTimeout(closePanels, 900);
+  } catch (e) {
+    $('imResult').innerHTML = '<div class="bad">✗ ' + esc(e.message) + '</div>';
+    $('imGo').disabled = false;
+  } finally {
+    $('imGo').textContent = '开始导入';
+  }
+}
+
+/* ---------------- 面板开关 ---------------- */
+function closePanels() {
+  ['thPanel', 'imPanel', 'txPanel', 'usPanel'].forEach(id => $(id).classList.remove('on'));
+  ['btnTh', 'btnImport', 'btnText', 'btnUsers'].forEach(id => {
+    const el = $(id); if (el) el.classList.remove('on');
+  });
+  $('dw').classList.remove('on');
+  closePreview();
+  closeReport();
+  const ov = $('ov');
+  ov.classList.remove('on', 'hi');
+}
+function openThresholds() {
+  if (!state.data || state.data.empty) { toast('先导入数据再配置指标'); return; }
+  closePanels();
+  buildThresholdPanel();
+  $('thPanel').classList.add('on');
+  $('btnTh').classList.add('on');
+  $('ov').classList.add('on', 'hi');
+}
+
+/* ---------------- 近30天数据同步 ---------------- */
+let syncTimer = null;
+
+/* 同步确认弹窗:这一步会消耗配额、要跑几分钟,不能点一下就直接执行 */
+function syncWindowText() {
+  const end = new Date();
+  const start = new Date(end.getTime() - 29 * 86400000);
+  const f = d => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' +
+    String(d.getDate()).padStart(2, '0');
+  return f(start) + ' ~ ' + f(end);
+}
+
+function openSyncConfirm() {
+  api('/api/sync/status').then(st => {
+    const s = st.state || {};
+    if (s.running) {
+      toast('同步正在进行中(' + (s.last_start || '').slice(11) + ' 开始),完成后会自动刷新', 4000);
+      watchSync();
+      return;
+    }
+    showSyncDialog(st);
+  }).catch(() => showSyncDialog({}));
+}
+
+function showSyncDialog(st) {
+  const old = $('cfWrap');
+  if (old) old.remove();
+  const last = (st.last_sync || '').replace('T', ' ').slice(0, 16);
+  const wrap = document.createElement('div');
+  wrap.id = 'cfWrap';
+  wrap.className = 'cfwrap';
+  wrap.innerHTML =
+    '<div class="cfcard">' +
+      '<div class="cfhead"><b>确认同步近30天数据?</b><span class="pvclose" id="cfClose">&times;</span></div>' +
+      '<div class="cfbody">' +
+        '<div class="cfrow"><span class="k">统计窗口</span><span class="v">' + syncWindowText() + '</span></div>' +
+        '<div class="cfrow"><span class="k">数据来源</span><span class="v">抖音来客 · 生意经报表集市</span></div>' +
+        '<div class="cfrow"><span class="k">预计耗时</span><span class="v">3 ~ 8 分钟(平台排队时间不定)</span></div>' +
+        '<div class="cfrow"><span class="k">会消耗</span><span class="v">1 次建任务配额(每天约 30 次)</span></div>' +
+        '<div class="cfrow"><span class="k">上次同步</span><span class="v">' + (last || '还没有同步过') + '</span></div>' +
+        '<div class="cfnote">同步期间会弹出一个浏览器窗口,<b>不影响你继续使用工作台</b>;' +
+        '完成后数据会自动刷新。系统每天 01:00 也会自动同步一次。</div>' +
+      '</div>' +
+      '<div class="cffoot">' +
+        '<button id="cfCancel">取消</button>' +
+        '<button class="primary" id="cfOk">开始同步</button>' +
+      '</div>' +
+    '</div>';
+  document.body.appendChild(wrap);
+  requestAnimationFrame(() => wrap.classList.add('on'));
+
+  const close = () => { wrap.classList.remove('on'); setTimeout(() => wrap.remove(), 200); };
+  $('cfClose').onclick = close;
+  $('cfCancel').onclick = close;
+  wrap.onclick = e => { if (e.target === wrap) close(); };
+  $('cfOk').onclick = () => { close(); startSync(); };
+  document.addEventListener('keydown', function esc(e) {
+    if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
+  });
+}
+async function startSync() {
+  try {
+    await api('/api/sync', { method: 'POST' });
+    toast('已开始同步近30天数据,约 3-5 分钟(期间可以继续用)', 4200);
+    watchSync();
+  } catch (e) {
+    toast('同步启动失败:' + e.message, 4200);
+  }
+}
+async function watchSync() {
+  clearInterval(syncTimer);
+  syncTimer = setInterval(async () => {
+    try {
+      const s = await api('/api/sync/status');
+      const st = s.state || {};
+      if (!st.running) {
+        clearInterval(syncTimer);
+        if (st.last_ok) {
+          toast('数据同步完成,正在刷新', 4000);
+          await load(state.period);
+        } else {
+          toast('同步失败:' + (st.last_msg || '看服务窗口日志'), 6000);
+        }
+      }
+    } catch (e) { clearInterval(syncTimer); }
+  }, 5000);
+}
+
+/* ---------------- 导出 ---------------- */
+/* 导出 = 下载原始文件,逻辑在 downloadOriginal / downloadBatchFile */
+
+/* ---------------- 事件 ---------------- */
+function bind() {
+  $('period').onchange = e => { state.period = e.target.value; load(e.target.value); };
+  $('q').oninput = e => { state.q = e.target.value.trim(); state.page = 0; renderTable(); };
+  $('fcity').onchange = e => { state.city = e.target.value; state.page = 0; renderTable(); };
+  $('fcat').onchange = e => { state.cat = e.target.value; state.page = 0; renderTable(); };
+  $('fscore').onchange = e => { state.score = e.target.value; state.page = 0; renderTable(); };
+  $('fsort').onchange = e => { state.sort = e.target.value; state.page = 0; renderTable(); };
+  $('forder').onchange = e => { state.order = e.target.value; state.page = 0; renderTable(); };
+  $('prev').onclick = () => { state.page--; renderTable(); };
+  $('next').onclick = () => { state.page++; renderTable(); };
+
+  $('btnTh').onclick = () => $('thPanel').classList.contains('on') ? closePanels() : openThresholds();
+  $('btnText').onclick = () => $('txPanel').classList.contains('on') ? closePanels() : openTexts();
+  $('btnImport').onclick = openImport;
+  $('btnSync').onclick = openSyncConfirm;
+  $('btnUsers').onclick = () => $('usPanel').classList.contains('on') ? closePanels() : openUsers();
+  $('usClose').onclick = closePanels;
+  $('usNew').onclick = openUserNew;
+  $('btnPw').onclick = () => showChangePw(false);
+  $('btnLogout').onclick = async () => {
+    await fetch('/api/logout', { method: 'POST' }).catch(() => {});
+    location.replace('/login.html');
+  };
+  $('btnExportCur').onclick = downloadOriginal;
+  $('thApply').onclick = applyThresholds;
+  $('thReset').onclick = resetThresholds;
+  $('thClose').onclick = closePanels;
+  $('txApply').onclick = applyTexts;
+  $('txReset').onclick = resetTexts;
+  $('txClose').onclick = closePanels;
+  $('imClose').onclick = closePanels;
+  $('imCancel').onclick = closePanels;
+  $('imGo').onclick = doImport;
+  $('ov').onclick = closePanels;
+  document.addEventListener('keydown', e => {
+    if (e.key === 'Escape') {
+      const rp = $('rpWrap');
+      const pv = $('pvWrap');
+      if (rp && rp.classList.contains('on')) closeReport();
+      else if (pv && pv.classList.contains('on')) closePreview();
+      else closePanels();
+    }
+  });
+
+  const drop = $('drop'), file = $('file');
+  drop.onclick = () => file.click();
+  file.onchange = () => {
+    pickedFile = file.files[0] || null;
+    $('dropText').textContent = pickedFile ? pickedFile.name : '点击选择 CSV / Excel 文件,或把文件拖到这里';
+    $('imGo').disabled = !pickedFile;
+  };
+  ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
+  ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
+  drop.addEventListener('drop', e => {
+    const f = e.dataTransfer.files[0];
+    if (!f) return;
+    if (!/\.(csv|xlsx)$/i.test(f.name)) { toast('只支持 CSV 或 .xlsx 文件(旧版 .xls 请先另存为 .xlsx)'); return; }
+    pickedFile = f;
+    $('dropText').textContent = f.name;
+    $('imGo').disabled = false;
+  });
+  window.addEventListener('resize', () => { [c1, c2, c3].forEach(c => c && c.resize()); });
+}
+
+function handleHash() {
+  const h = decodeURIComponent((location.hash || '').slice(1));
+  if (!h) return;
+  if (h === 'TH') return openThresholds();
+  if (h === 'TX') return openTexts();
+  if (h === 'IM') return openImport();
+  if (state.data && !state.data.empty) openDrawer(h);
+}
+
+window.addEventListener('hashchange', handleHash);
+bind();
+(async function boot() {
+  try {
+    const me = await api('/api/me');
+    state.me = me.user;
+    applyPerms();
+    await load();
+    handleHash();
+  } catch (e) {
+    if (!String(e.message).includes('登录')) toast('加载失败:' + e.message, 4000);
+  }
+})();

+ 180 - 0
store-ops/frontend/index.html

@@ -0,0 +1,180 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>门店检测工作台</title>
+<link rel="stylesheet" href="/style.css">
+<link rel="stylesheet" href="/report.css">
+<script src="/vendor/echarts.min.js"></script>
+</head>
+<body>
+<div class="wrap">
+  <header>
+    <div class="hleft">
+      <div class="htop">
+        <span class="brand">怡呵美 · 水光会员店</span>
+        <h1>门店检测工作台</h1>
+      </div>
+      <div class="hsub">
+        <span id="ruleNote">规则来源:《门店考核指标》官方基准线</span>
+        <span id="dataNote"></span>
+      </div>
+    </div>
+    <div class="hright">
+      <select id="period" class="period" title="选择周期"></select>
+      <div class="huser">
+        <span class="uavatar" id="whoAvatar">管</span>
+        <span class="uinfo2"><b id="whoName"></b><i id="whoRole"></i></span>
+        <button class="ubtn2" id="btnPw" title="修改我的密码">改密码</button>
+        <button class="ubtn2 danger" id="btnLogout" title="退出登录">退出登录</button>
+      </div>
+    </div>
+  </header>
+
+  <div class="kpis" id="kpis"></div>
+
+  <div class="charts">
+    <div class="panel"><h3>问题命中分布(按类型)</h3><div class="chart" id="c1"></div></div>
+    <div class="panel"><h3 id="c2title">核销金额分布</h3><div class="chart" id="c2"></div></div>
+    <div class="panel"><h3>门店数 TOP15 城市</h3><div class="chart" id="c3"></div></div>
+  </div>
+
+  <div class="bar">
+    <input id="q" placeholder="搜索门店名称或ID">
+    <select id="fcity"><option value="">全部城市</option></select>
+    <select id="fscore">
+      <option value="">全部评分</option>
+      <option value="lt40">评分 4.0 以下</option>
+      <option value="40_43">评分【4.0-4.3】</option>
+      <option value="43_45">评分【4.3-4.5】</option>
+      <option value="gte45">评分 4.5 以上</option>
+    </select>
+    <select id="fcat"><option value="">全部问题类型</option></select>
+    <select id="fsort" title="排序字段">
+      <option value="sev">按严重度</option>
+      <option value="amt">按核销金额</option>
+      <option value="score">按评分</option>
+      <option value="hx">按核销率</option>
+      <option value="visit">按访问量</option>
+    </select>
+    <select id="forder" title="排序方向">
+      <option value="desc">倒序</option>
+      <option value="asc">正序</option>
+    </select>
+    <span class="sub" id="cnt"></span>
+  </div>
+
+  <div class="panel tablewrap" style="padding:4px 8px 10px">
+    <table><thead><tr>
+      <th>门店</th><th>城市</th>
+      <th class="num">评分</th>
+      <th class="num">访问</th>
+      <th class="num">成交券</th>
+      <th class="num">核销率</th>
+      <th class="num">核销金额</th>
+      <th class="num">待核销</th>
+      <th class="num">退单率</th>
+      <th class="num">投诉率</th>
+      <th class="num">差评率</th>
+      <th class="num">回复率</th>
+      <th class="num">上榜</th>
+      <th>触发问题</th>
+    </tr></thead><tbody id="tb"></tbody></table>
+    <div class="pager"><span id="pginfo"></span><button id="prev">上一页</button><button id="next">下一页</button></div>
+  </div>
+
+  <div class="empty" id="empty" hidden>
+    <div class="empty-ic">📥</div>
+    <div class="empty-t">还没有数据</div>
+    <div class="empty-d">点左侧【导入数据】上传抖音后台导出的 CSV / Excel,或把文件丢进 <code>data/inbox</code> 目录等 20 秒自动导入。</div>
+    <button class="primary" onclick="document.getElementById('btnImport').click()">导入数据</button>
+  </div>
+</div>
+
+<aside class="rail">
+  <button class="railitem" id="btnText">文案配置</button>
+  <button class="railitem" id="btnTh">指标配置<span class="rdot"></span></button>
+  <button class="railitem" id="btnSync">同步数据</button>
+  <button class="railitem" id="btnImport">导入数据</button>
+  <button class="railitem" id="btnUsers">账号管理</button>
+</aside>
+
+<div class="overlay" id="ov"></div>
+<div class="drawer" id="dw"></div>
+
+<!-- 考核指标面板 -->
+<aside class="sidepanel" id="thPanel">
+  <div class="sphead">
+    <h4>指标配置 <span class="pill" id="thPill">默认值</span>
+      <span class="spclose" id="thClose" title="关闭">&times;</span></h4>
+    <p>门店考核指标参数,默认值来自《门店考核指标》官方基准线。修改后点【应用到工作台】,服务端会按新阈值重算全部门店。</p>
+  </div>
+  <div class="spbody" id="thBody"></div>
+  <div class="spfoot">
+    <button id="thReset">恢复默认</button>
+    <button class="primary" id="thApply">应用到工作台</button>
+  </div>
+</aside>
+
+<!-- 文案配置面板 -->
+<aside class="sidepanel" id="txPanel">
+  <div class="sphead">
+    <h4>文案配置 <span class="pill" id="txPill">默认值</span>
+      <span class="spclose" id="txClose" title="关闭">&times;</span></h4>
+    <p>自定义门店分析反馈文案。大括号变量在生成时自动替换为该店真实数据(如 {v}、{base}、{门店名}),删掉变量就是固定文字。改完点【应用到工作台】立即对全部门店生效。</p>
+  </div>
+  <div class="spbody" id="txBody"></div>
+  <div class="spfoot">
+    <button id="txReset">恢复默认</button>
+    <button class="primary" id="txApply">应用到工作台</button>
+  </div>
+</aside>
+
+<!-- 导入面板 -->
+<aside class="sidepanel" id="usPanel">
+  <div class="sphead">
+    <h4>账号管理 <span class="spclose" id="usClose" title="关闭">&times;</span></h4>
+    <p>账号按角色分配权限:<b>管理员</b>全部功能;<b>运营</b>可导入、同步、导出,不能改判定规则;<b>查看</b>只能看数据。</p>
+  </div>
+  <div class="spbody">
+    <button class="primary" id="usNew" style="width:100%;margin-bottom:14px">+ 新建账号</button>
+    <div class="history" id="usList"></div>
+  </div>
+</aside>
+
+<aside class="sidepanel" id="imPanel">
+  <div class="sphead">
+    <h4>导入门店数据 <span class="spclose" id="imClose" title="关闭">&times;</span></h4>
+    <p>支持门店后台导出的 CSV 或 Excel(.xlsx),日数据或整月汇总都行。同一门店同一天重复导入会覆盖,不会翻倍。旧版 .xls 请先另存为 .xlsx。</p>
+  </div>
+  <div class="spbody">
+    <label class="drop" id="drop">
+      <input type="file" id="file" accept=".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" hidden>
+      <span id="dropText">点击选择 CSV / Excel 文件,或把文件拖到这里</span>
+    </label>
+    <div class="frow">
+      <div class="thfield"><label>归属月份</label><div class="inp"><input id="imPeriod" type="month"></div></div>
+      <div class="thfield"><label>数据日期(选填)</label><div class="inp"><input id="imDate" type="date"></div></div>
+    </div>
+    <div class="thnote" style="padding:0">· 文件里有「日期」列时以列值为准;填了日期则按日入库;都不填则当作该月汇总。</div>
+    <div id="imResult" class="imresult"></div>
+    <div class="sphead" style="padding:18px 0 6px;border:0">
+      <h4 style="font-size:13px">导出原始文件</h4>
+      <p>导出的是生意经导出的原件,与导入时完全一致,不含任何计算。</p>
+    </div>
+    <button class="primary" id="btnExportCur" style="width:100%;margin-bottom:6px">下载当前周期最新一份</button>
+    <div class="thnote" style="padding:6px 0 0">下面每条记录右侧的「原始文件」可以下载那一次导入的原件。</div>
+    <div class="sphead" style="padding:14px 0 6px;border:0"><h4 style="font-size:13px">导入历史</h4></div>
+    <div id="imHistory" class="history"></div>
+  </div>
+  <div class="spfoot">
+    <button id="imCancel">取消</button>
+    <button class="primary" id="imGo" disabled>开始导入</button>
+  </div>
+</aside>
+
+<div class="toast" id="toast"></div>
+<script src="/app.js"></script>
+</body>
+</html>

+ 116 - 0
store-ops/frontend/login.html

@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>登录 · 门店检测工作台</title>
+<style>
+  :root{
+    --bg:#fbf7ef; --surface:#fff; --line:#ece0c8; --ink:#211a10; --ink-2:#5b4f3a;
+    --ink-3:#93876f; --ink-4:#b9ad95; --gold:#eaba4e; --gold-deep:#a97b12;
+    --gold-bg:#fdf6e4; --grad-gold:linear-gradient(180deg,#f3c95f,#e0a52e);
+  }
+  *{box-sizing:border-box;margin:0;padding:0}
+  body{
+    font-family:"PingFang SC","Microsoft YaHei",-apple-system,system-ui,sans-serif;
+    color:var(--ink); min-height:100vh; display:flex; align-items:center; justify-content:center;
+    padding:24px;
+    background:
+      radial-gradient(900px 400px at 15% -120px,rgba(234,185,78,.22),transparent 68%),
+      radial-gradient(800px 360px at 85% -60px,rgba(234,185,78,.14),transparent 66%),
+      radial-gradient(700px 500px at 50% 110%,rgba(234,185,78,.08),transparent 70%),
+      var(--bg);
+  }
+  .card{width:100%;max-width:400px;background:var(--surface);border:1px solid var(--line);
+    border-radius:18px;padding:34px 30px 28px;box-shadow:0 24px 60px rgba(150,110,20,.14)}
+  .brand{display:flex;align-items:center;gap:10px;margin-bottom:26px}
+  .logo{width:34px;height:34px;border-radius:10px;background:var(--grad-gold);
+    display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px;
+    box-shadow:0 4px 12px rgba(181,126,21,.28)}
+  .brand h1{font-size:17px;font-weight:600;letter-spacing:-.2px}
+  .brand p{font-size:11.5px;color:var(--ink-3);margin-top:2px}
+  label{display:block;font-size:12.5px;color:var(--ink-3);margin-bottom:6px}
+  input{width:100%;padding:11px 13px;font-size:14.5px;font-family:inherit;color:var(--ink);
+    background:var(--surface);border:1px solid var(--line);border-radius:10px;outline:none;
+    transition:border-color .15s,box-shadow .15s}
+  input:focus{border-color:var(--gold);box-shadow:0 0 0 3px rgba(234,185,78,.16)}
+  .field{margin-bottom:15px}
+  button{width:100%;margin-top:8px;padding:12px;font-size:15px;font-family:inherit;font-weight:600;
+    color:#fff;background:var(--grad-gold);border:1px solid #b57e15;border-radius:10px;cursor:pointer;
+    box-shadow:0 3px 12px rgba(181,126,21,.28);transition:filter .15s}
+  button:hover{filter:brightness(1.05)}
+  button:disabled{opacity:.6;cursor:default}
+  .err{margin-top:14px;padding:9px 12px;background:#fdf0ea;border:1px solid #f3d4c6;
+    border-radius:9px;color:#a4462a;font-size:13px;display:none}
+  .err.on{display:block}
+  .hint{margin-top:18px;padding:11px 13px;background:var(--gold-bg);border:1px solid #f0dfb4;
+    border-radius:10px;font-size:12.5px;line-height:1.7;color:var(--ink-2);display:none}
+  .hint.on{display:block}
+  .hint b{color:var(--gold-deep)}
+  .foot{margin-top:22px;text-align:center;font-size:11.5px;color:var(--ink-4);line-height:1.8}
+</style>
+</head>
+<body>
+<div class="card">
+  <div class="brand">
+    <div class="logo">店</div>
+    <div>
+      <h1>门店检测工作台</h1>
+      <p>怡呵美 · 水光会员店</p>
+    </div>
+  </div>
+
+  <form id="f" autocomplete="on">
+    <div class="field">
+      <label for="u">账号</label>
+      <input id="u" name="username" autocomplete="username" autocapitalize="off" spellcheck="false" autofocus>
+    </div>
+    <div class="field">
+      <label for="p">密码</label>
+      <input id="p" name="password" type="password" autocomplete="current-password">
+    </div>
+    <button id="btn" type="submit">登 录</button>
+    <div class="err" id="err"></div>
+  </form>
+
+  <div class="hint" id="hint">
+    首次使用:账号 <b>admin</b>,初始密码 <b>admin123</b><br>
+    登录后会要求你立刻修改密码。
+  </div>
+
+  <div class="foot">账号由管理员统一分配<br>忘记密码请联系管理员重置</div>
+</div>
+
+<script>
+const $ = id => document.getElementById(id);
+const err = m => { $('err').textContent = m; $('err').classList.add('on'); };
+
+fetch('/api/auth/state').then(r => r.json()).then(s => {
+  if (s.logged_in) location.replace('/');
+  if (s.default_admin) $('hint').classList.add('on');
+  if (s.needs_setup) $('hint').classList.add('on');
+}).catch(() => {});
+
+$('f').onsubmit = async e => {
+  e.preventDefault();
+  const username = $('u').value.trim(), password = $('p').value;
+  if (!username || !password) return err('请输入账号和密码');
+  $('btn').disabled = true; $('btn').textContent = '登录中…';
+  $('err').classList.remove('on');
+  try {
+    const r = await fetch('/api/login', {
+      method: 'POST', headers: {'Content-Type': 'application/json'},
+      body: JSON.stringify({username, password}),
+    });
+    const d = await r.json();
+    if (!r.ok) throw new Error(d.error || '登录失败');
+    location.replace('/');
+  } catch (e) {
+    err(e.message);
+    $('btn').disabled = false; $('btn').textContent = '登 录';
+    $('p').select();
+  }
+};
+</script>
+</body>
+</html>

+ 163 - 0
store-ops/frontend/report.css

@@ -0,0 +1,163 @@
+/* 门店检测工作台 —— 单店分析报告(A4 可打印)
+   独立样式表:屏幕上是弹层,打印/另存为 PDF 时只留报告正文。
+   下载成单文件 HTML 时本文件会被整段内联,所以这里不要引用外部资源。 */
+
+.rp{
+  --rp-ink:#211a10; --rp-ink2:#5c5241; --rp-ink3:#93876f; --rp-line:#e8dcc0;
+  --rp-line2:#f2e9d6; --rp-gold:#c8952c; --rp-gold-bg:#fbf4e2; --rp-brown:#a4761c;
+  --rp-red:#b5563a; --rp-amber:#cd8a35; --rp-green:#5f8d72;
+  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
+  color:var(--rp-ink); font-size:12.5px; line-height:1.7;
+  -webkit-font-smoothing:antialiased;
+}
+
+/* ================= 屏幕:弹层 ================= */
+.rpwrap{position:fixed;inset:0;z-index:60;display:none;overflow:auto;
+  background:rgba(38,29,14,.42);backdrop-filter:blur(3px);padding:32px 20px 48px}
+.rpwrap.on{display:block}
+.rpcard{width:min(880px,100%);margin:0 auto;background:#fff;border-radius:14px;
+  box-shadow:0 24px 70px rgba(60,44,12,.28);overflow:hidden;animation:rpin .22s ease}
+@keyframes rpin{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
+
+.rptoolbar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:10px;
+  padding:12px 20px;background:#fdf8ee;border-bottom:1px solid var(--rp-line)}
+.rptoolbar .rpname{flex:1;min-width:0;font-size:13px;font-weight:600;color:var(--rp-ink);
+  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.rptoolbar .rpsub{font-size:11px;color:var(--rp-ink3);font-weight:400;margin-left:8px}
+.rpbtn{font-family:inherit;font-size:12px;padding:7px 14px;border-radius:8px;cursor:pointer;
+  border:1px solid var(--rp-line);background:#fff;color:var(--rp-ink2);transition:.15s}
+.rpbtn:hover{border-color:var(--rp-gold);color:var(--rp-brown)}
+.rpbtn.primary{background:linear-gradient(135deg,#eab94e,#d29a29 48%,#b57e15);color:#fff;
+  border-color:transparent;font-weight:600;box-shadow:0 3px 10px rgba(180,126,21,.26),
+  inset 0 1px 0 rgba(255,255,255,.32)}
+.rpbtn.primary:hover{filter:brightness(1.04)}
+.rpbtn.x{width:32px;padding:7px 0;text-align:center;font-size:15px;line-height:1;color:var(--rp-ink3)}
+
+/* ================= 报告正文(屏幕 + 打印 + 下载共用) ================= */
+.rpdoc{padding:28px 40px 32px;background:#fff}
+/* 报告头:显式 block,避免被 style.css 里的全局 header{display:flex} 带跑偏 */
+.rphead{display:block;padding-bottom:16px;border-bottom:1.5px solid var(--rp-gold);position:relative}
+.rphead::after{display:none}
+.rphead .rpbrand{display:inline-block;background:linear-gradient(135deg,#eab94e,#c8952c 55%,#a4761c);
+  color:#fff;font-size:10.5px;letter-spacing:1px;padding:3px 10px;border-radius:5px;font-weight:600}
+.rphead h1{font-size:20px;font-weight:600;letter-spacing:-.2px;margin:9px 0 6px;line-height:1.3}
+.rpmeta{display:flex;flex-wrap:wrap;gap:4px 18px;font-size:11px;color:var(--rp-ink3);letter-spacing:.1px}
+.rpmeta b{font-weight:500;color:var(--rp-ink2)}
+
+/* 结论摘要 */
+.rpsum{display:flex;flex-wrap:wrap;gap:10px 18px;margin:16px 0 2px;padding:13px 17px;
+  background:var(--rp-gold-bg);border:1px solid #f0e2c2;border-radius:10px}
+.rpsum .rplead{flex:0 0 auto;min-width:132px}
+.rpsum .rplead .k{font-size:11px;color:var(--rp-ink3);letter-spacing:.3px}
+.rpsum .rplead .v{font-size:26px;font-weight:600;line-height:1.2;margin:2px 0 4px;color:var(--rp-ink)}
+.rpsum .rplead .v .u{font-size:12px;font-weight:400;color:var(--rp-ink3);margin-left:6px}
+.rpsev{display:inline-block;font-size:11px;padding:2px 9px;border-radius:20px;font-weight:600}
+.rpsev.s3{background:#f7e0d7;color:#93401f}
+.rpsev.s2{background:#fae8d4;color:#9a5a1c}
+.rpsev.s1{background:#f8efd6;color:#8a6a2f}
+.rpsev.n{background:#efeade;color:#6b6150}
+.rpsev.s0{background:#e9f1ea;color:#4a7458}
+.rpstats{display:flex;flex-wrap:wrap;gap:8px 20px;align-items:center;justify-content:flex-end;flex:1 1 auto}
+.rpstats div{text-align:center;min-width:62px}
+.rpstats b{display:block;font-size:17px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--rp-ink)}
+.rpstats span{font-size:10.5px;color:var(--rp-ink3)}
+
+/* 章节 */
+.rpdoc h2{display:flex;align-items:center;gap:9px;font-size:14px;font-weight:600;
+  margin:24px 0 12px;padding-left:11px;position:relative;letter-spacing:.1px;
+  break-after:avoid;page-break-after:avoid;break-inside:avoid}
+.rpdoc h2::before{content:"";position:absolute;left:0;top:2px;bottom:2px;width:3px;
+  border-radius:2px;background:var(--rp-gold)}
+.rpdoc h2 .n{color:var(--rp-gold);font-weight:600}
+
+/* 指标条 */
+.rpmetrics{display:grid;grid-template-columns:1fr 1fr;gap:2px 30px}
+.rpbar{margin:7px 0;break-inside:avoid}
+.rpbar .lab{display:flex;justify-content:space-between;font-size:11px;color:var(--rp-ink3)}
+.rpbar .lab b{font-weight:500;color:var(--rp-ink2);font-variant-numeric:tabular-nums}
+.rpbar .track{position:relative;height:6px;background:#f5ecda;border-radius:4px;margin-top:6px}
+.rpbar .fill{height:100%;border-radius:4px}
+.rpbar .mk{position:absolute;top:-3px;width:1.5px;height:12px;background:#c6b48c}
+.rpbar .mk.t{background:var(--rp-green)}
+.rplegend{margin-top:12px;font-size:10.5px;color:var(--rp-ink3)}
+.rplegend i{display:inline-block;width:1.5px;height:10px;background:#c6b48c;margin:0 4px 0 12px;vertical-align:-1px}
+.rplegend i.t{background:var(--rp-green)}
+
+/* 风险条目(表格样式自带全,不依赖 style.css,保证下载的单文件 HTML 长得一样) */
+.rpflags{width:100%;border-collapse:collapse;font-size:11.5px}
+.rpflags th{text-align:left;font-weight:500;font-size:10.5px;color:#8a7548;letter-spacing:.3px;
+  padding:6px 0 7px;background:#fdf9ee;border-bottom:1px solid #e8d8b2;border-radius:0;white-space:nowrap}
+.rpflags th:first-child{padding-left:2px}
+.rpflags th:last-child{padding-left:12px}
+.rpflags td{padding:7px 0;border-bottom:1px solid var(--rp-line2);vertical-align:top}
+.rpflags tr:last-child td{border-bottom:0}
+.rpflags td.no{width:26px;color:var(--rp-ink3);font-variant-numeric:tabular-nums}
+.rpflags td.act{color:var(--rp-ink2);width:44%;padding-left:12px}
+.rpdx{margin:0 0 11px;padding:10px 13px;background:#fdfaf3;border-left:3px solid var(--rp-gold);
+  border-radius:0 8px 8px 0;font-size:11.5px;line-height:1.7;color:var(--rp-ink2)}
+.rpflag{display:inline-block;font-size:9.5px;color:#93401f;background:#f7e0d7;
+  padding:1px 6px;border-radius:4px;margin-left:7px;vertical-align:1px;font-weight:600}
+.rptag{display:inline-block;padding:1px 7px;border-radius:5px;font-size:10.5px}
+.rptag.R3{background:#f7e0d7;color:#93401f}
+.rptag.R2{background:#fae8d4;color:#9a5a1c}
+.rptag.R1{background:#f8efd6;color:#8a6a2f}
+.rptag.B{background:#f0e6d0;color:#5f4b28}
+.rptag.T{background:#efeade;color:#6b6150}
+
+/* 数据小卡 */
+.rpgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
+.rpgrid div{padding:9px 11px;background:#fdfaf3;border:1px solid var(--rp-line2);border-radius:8px}
+.rpgrid .k{font-size:10.5px;color:var(--rp-ink3);letter-spacing:.2px}
+.rpgrid .v{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums;margin-top:2px}
+.rpgrid .v small{font-size:10.5px;font-weight:400;color:var(--rp-ink3);margin-left:3px}
+
+/* 洞察 / 动作 */
+.rpol{padding-left:0;margin:0}
+.rpol li{list-style:none;position:relative;padding:2px 0 6px 24px;font-size:11.5px;line-height:1.75;
+  color:var(--rp-ink2);break-inside:avoid}
+.rpol li .idx{position:absolute;left:0;top:3px;width:17px;height:17px;border-radius:50%;
+  background:var(--rp-gold-bg);color:var(--rp-brown);font-size:10px;font-weight:600;
+  text-align:center;line-height:17px;font-variant-numeric:tabular-nums}
+.rpacts{margin:0;padding:0;background:#fdfaf3;border:1px solid var(--rp-line2);
+  border-radius:9px;padding:13px 16px}
+.rpacts li{list-style:none;position:relative;padding:3px 0 8px 24px;font-size:11.5px;line-height:1.75;
+  color:var(--rp-ink2);break-inside:avoid}
+.rpacts li:last-child{padding-bottom:2px}
+.rpacts li .ic{position:absolute;left:0;top:3px;font-size:11px;color:var(--rp-green)}
+.rpmuted{color:var(--rp-ink3);font-size:11.5px}
+.rpfoot{margin-top:28px;padding-top:12px;border-top:1px solid var(--rp-line2);
+  font-size:10px;color:var(--rp-ink3);line-height:1.8;letter-spacing:.1px}
+
+/* ================= 打印 / 另存为 PDF ================= */
+@page{size:A4;margin:14mm}
+@media print{
+  html,body{background:#fff !important}
+  body.rp-on .wrap,
+  body.rp-on .rail,
+  body.rp-on .overlay,
+  body.rp-on .drawer,
+  body.rp-on .sidepanel,
+  body.rp-on .toast,
+  body.rp-on .pvwrap,
+  body.rp-on .rptoolbar{display:none !important}
+  body.rp-on .rpwrap{position:static !important;inset:auto;overflow:visible;
+    background:#fff !important;padding:0;backdrop-filter:none;display:block !important}
+  .rpcard{width:auto;max-width:none;box-shadow:none;border-radius:0;margin:0;animation:none}
+  .rpdoc{padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact}
+  .rpdoc h2{margin-top:16px}
+  .rpdoc h2 + *{break-before:avoid;page-break-before:avoid}
+  .rpsum{margin-top:12px}
+  .rpmetrics,.rpgrid{break-inside:avoid}
+}
+
+/* ================= 窄屏(手机上看报告) ================= */
+@media (max-width:640px){
+  .rpwrap{padding:14px 10px 30px}
+  .rpdoc{padding:22px 18px 26px}
+  .rphead h1{font-size:18px}
+  .rpmetrics{grid-template-columns:1fr}
+  .rpgrid{grid-template-columns:1fr 1fr}
+  .rpflags td.act{width:auto}
+  .rptoolbar{padding:10px 12px;flex-wrap:wrap}
+  .rptoolbar .rpname{flex:1 0 100%;margin-bottom:4px}
+}

+ 402 - 0
store-ops/frontend/style.css

@@ -0,0 +1,402 @@
+/* 门店检测工作台 —— 视觉系统
+   香槟金画布 + 白卡片 + 金描边;金色渐变主操作 + 暖棕文字;数据色保留语义可读性 */
+:root{
+  --bg:#f8f4ea;
+  --surface:#ffffff;
+  --line:#ecdfc2;
+  --line-soft:#f6efe0;
+  --ink:#211a10;
+  --ink-2:#5c5241;
+  --ink-3:#93876f;
+  --ink-4:#bcb09a;
+  --gold:#c8952c;
+  --gold-deep:#a4761c;
+  --gold-soft:#f1e2bc;
+  --gold-bg:#fbf4e2;
+  --grad-gold:linear-gradient(135deg,#eab94e 0%,#d29a29 48%,#b57e15 100%);
+  --brass:#a4761c;
+  --brass-bg:#fbf1da;
+  --accent:#c08f22;
+  --ok:#5f8d72;
+  --r-sm:8px; --r-md:12px; --r-lg:16px;
+  --shadow-1:0 1px 2px rgba(120,90,20,.05);
+  --shadow-2:0 10px 30px rgba(120,90,20,.10);
+}
+*{box-sizing:border-box;margin:0;padding:0}
+body{color:var(--ink);
+  background:
+    radial-gradient(1100px 400px at 12% -140px,rgba(234,185,78,.20),transparent 68%),
+    radial-gradient(900px 360px at 88% -80px,rgba(234,185,78,.13),transparent 66%),
+    radial-gradient(700px 500px at 50% 108%,rgba(234,185,78,.07),transparent 70%),
+    var(--bg);
+  background-attachment:fixed;
+  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
+  font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
+.wrap{max-width:1400px;margin:0 auto;padding:40px 36px 88px 72px}
+
+/* ---------- 顶部 ---------- */
+header{position:relative;display:flex;align-items:flex-start;justify-content:space-between;gap:20px;flex-wrap:nowrap;
+  padding-bottom:20px;border-bottom:1px solid var(--line)}
+header::after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:1.5px;
+  background:linear-gradient(90deg,var(--gold) 0%,rgba(200,149,44,.35) 26%,rgba(200,149,44,0) 62%)}
+.hleft{min-width:0;flex:1 1 auto}
+.htop{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.hsub{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:7px;color:var(--ink-3);font-size:13px;letter-spacing:.1px}
+.hsub span+span::before{content:"·";margin-right:10px;color:#d8c9a2}
+h1{font-size:21px;font-weight:600;letter-spacing:-.2px;line-height:1.3}
+.brand{background:var(--grad-gold);color:#fff;font-weight:600;font-size:12.5px;padding:4px 12px;
+  border-radius:6px;letter-spacing:.8px;white-space:nowrap;
+  box-shadow:0 2px 8px rgba(180,126,21,.28),inset 0 1px 0 rgba(255,255,255,.35)}
+.sub{color:var(--ink-3);font-size:13px;letter-spacing:.1px}
+.period{appearance:none;border:1px solid var(--line);background:var(--surface);border-radius:var(--r-sm);
+  padding:7px 30px 7px 12px;font-size:14px;font-family:inherit;color:var(--ink);outline:none;cursor:pointer;
+  font-weight:500;
+  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23a4761c' stroke-width='1.5'/></svg>");
+  background-repeat:no-repeat;background-position:right 11px center;transition:border-color .15s,box-shadow .15s}
+.period:hover{border-color:var(--gold)}
+.period:focus{border-color:var(--gold);box-shadow:0 0 0 3px rgba(200,149,44,.14)}
+
+/* ---------- KPI ---------- */
+.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin:26px 0 14px}
+.kpi{position:relative;overflow:hidden;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);
+  padding:16px 17px 14px;cursor:pointer;transition:border-color .15s,box-shadow .15s,transform .15s;
+  box-shadow:var(--shadow-1)}
+.kpi::before{content:"";position:absolute;top:0;left:0;right:0;height:2.5px;background:var(--grad-gold);
+  opacity:.5;transition:opacity .18s}
+.kpi:hover{border-color:#e2cfa3;box-shadow:var(--shadow-2);transform:translateY(-1px)}
+.kpi:hover::before{opacity:1}
+.kpi .v{font-size:26px;font-weight:600;letter-spacing:-.6px;font-variant-numeric:tabular-nums;white-space:nowrap;line-height:1.25}
+.kpi .v .u{font-size:13.5px;font-weight:500;color:var(--ink-3);letter-spacing:0;margin-left:4px}
+.kpi .l{color:var(--ink-3);font-size:13px;margin-top:3px;letter-spacing:.1px}
+.kpi.hot .v{color:#ab4a26}
+.kpi.on{border-color:var(--gold-deep);background:var(--gold-bg);box-shadow:inset 0 0 0 1px var(--gold-deep)}
+.kpi.on::before{opacity:1}
+.kpi.on .v{color:#8a5f0c}
+.kpi.hot.on{border-color:#ab4a26;box-shadow:inset 0 0 0 1px #ab4a26}
+.kpi.hot.on .v{color:#ab4a26}
+
+/* ---------- 图表 ---------- */
+.charts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:12px}
+.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-md);padding:22px 24px;box-shadow:var(--shadow-1)}
+.panel h3{font-size:13.5px;font-weight:500;color:var(--ink-2);letter-spacing:.3px;margin-bottom:8px;
+  display:flex;align-items:center;gap:7px}
+.panel h3::before{content:"";width:3px;height:11px;border-radius:2px;background:var(--grad-gold)}
+.chart{height:290px}
+
+/* ---------- 筛选栏 ---------- */
+.bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:20px 0 12px}
+.bar input,.bar select{background:var(--surface);border:1px solid var(--line);color:var(--ink);border-radius:var(--r-sm);
+  padding:8px 12px;font-size:14px;outline:none;font-family:inherit;transition:border-color .15s,box-shadow .15s}
+.bar input::placeholder{color:var(--ink-4)}
+.bar input:hover,.bar select:hover{border-color:#e0caa0}
+.bar input:focus,.bar select:focus{border-color:var(--gold);box-shadow:0 0 0 3px rgba(200,149,44,.13)}
+.bar input{width:230px}
+.bar select{max-width:190px;appearance:none;padding-right:28px;
+  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' fill='none' stroke='%23a4761c' stroke-width='1.5'/></svg>");
+  background-repeat:no-repeat;background-position:right 11px center;background-color:var(--surface)}
+.bar .sub{margin-left:auto;font-variant-numeric:tabular-nums}
+
+/* ---------- 表格 ---------- */
+table{width:100%;border-collapse:collapse;font-size:14.5px}
+th{color:#8a7548;font-weight:600;font-size:13px;letter-spacing:.4px;padding:12px 8px;
+  background:#fdf9ee;border-bottom:1px solid #e8d8b2;text-align:left;white-space:nowrap;user-select:none}
+th:first-child{border-top-left-radius:8px}
+th:last-child{border-top-right-radius:8px}
+th.num,td.num{text-align:right;font-variant-numeric:tabular-nums}
+td{padding:13px 8px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
+.tablewrap{overflow-x:auto}
+td.num,th.num{white-space:nowrap}
+.cl-name{min-width:150px}
+.cl-city{white-space:nowrap;color:var(--ink-3);font-size:12.5px;padding-left:6px;padding-right:6px}
+.cl-tags{max-width:320px;min-width:200px}
+.muted{color:var(--ink-4);font-size:12px;font-weight:400}
+.cl-rank{white-space:nowrap}
+.good-text{color:#3f6d55;font-weight:600}
+.warn-text{color:#8a6a2f}
+.rankbox{margin:12px 0;padding:10px 12px;background:#fdfaf2;border:1px solid var(--line-soft);
+  border-left:3px solid var(--gold);border-radius:0 8px 8px 0;font-size:12.5px;line-height:1.7;color:var(--ink-2)}
+.rankbox .rk{display:inline-block;font-size:11px;color:var(--gold-deep);background:var(--gold-bg);
+  border-radius:4px;padding:0 6px;margin-right:8px}
+tr.row{transition:background .12s}
+tr.row:hover td{background:#fdf9f0;cursor:pointer}
+tr.row:last-child td{border-bottom:0}
+.nm{font-weight:500;color:var(--ink);letter-spacing:-.1px}
+.sid{color:var(--ink-4);font-size:12px;font-variant-numeric:tabular-nums;letter-spacing:.2px}
+.tag{display:inline-block;padding:1px 6px;border-radius:5px;font-size:11.5px;margin:1.5px 3px 1.5px 0;
+  white-space:nowrap;letter-spacing:.1px;font-weight:500}
+.t-R3{background:#f7e0d7;color:#93401f}
+.t-R2{background:#fae3c4;color:#7d4d0c}
+.t-R1{background:#f7ecbe;color:#6f5610}
+.t-B{background:#f0e6d0;color:#5f4b28}
+.t-T{background:#efeade;color:#6b6150}
+.dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:7px;vertical-align:1px}
+.pager{display:flex;gap:8px;align-items:center;justify-content:flex-end;color:var(--ink-3);font-size:13px;padding:14px 4px 4px}
+.pager button{background:var(--surface);border:1px solid var(--line);color:var(--ink-2);border-radius:var(--r-sm);
+  padding:5px 14px;cursor:pointer;font-family:inherit;font-size:13.5px;transition:border-color .15s,color .15s,background .15s}
+.pager button:hover{border-color:var(--gold);color:var(--gold-deep);background:var(--gold-bg)}
+.pager button:disabled{opacity:.35;cursor:default;border-color:var(--line);background:var(--surface)}
+
+/* ---------- 空态 ---------- */
+.empty{background:var(--surface);border:1px solid var(--line);border-radius:var(--r-lg);padding:72px 24px;text-align:center;box-shadow:var(--shadow-1)}
+.empty-ic{font-size:32px;opacity:.55}
+.empty-t{font-size:16px;font-weight:600;margin-top:12px;letter-spacing:-.2px}
+.empty-d{color:var(--ink-3);font-size:14px;margin:8px auto 20px;max-width:520px;line-height:1.9}
+.empty code{background:var(--gold-bg);padding:1.5px 6px;border-radius:5px;font-size:13px;color:var(--gold-deep)}
+.primary{background:var(--grad-gold);border:1px solid #b57e15;color:#fff;font-weight:600;border-radius:var(--r-sm);
+  padding:9px 18px;font-size:14px;cursor:pointer;font-family:inherit;letter-spacing:.2px;
+  box-shadow:0 2px 9px rgba(181,126,21,.26),inset 0 1px 0 rgba(255,255,255,.3);transition:filter .15s,box-shadow .15s}
+.primary:hover{filter:brightness(1.05);box-shadow:0 4px 14px rgba(181,126,21,.34),inset 0 1px 0 rgba(255,255,255,.3)}
+.primary:disabled{opacity:.45;cursor:default;box-shadow:none}
+
+/* ---------- 左侧菜单 ---------- */
+.rail{position:fixed;left:14px;top:50%;transform:translateY(-50%);z-index:8;display:flex;flex-direction:column;gap:2px;
+  background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:8px 5px;
+  box-shadow:0 10px 30px rgba(150,110,20,.13)}
+.railitem{position:relative;background:none;border:0;cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--ink-3);
+  writing-mode:vertical-rl;letter-spacing:2.5px;padding:15px 6px;border-radius:9px;transition:background .15s,color .15s}
+.railitem:hover{background:var(--gold-bg);color:var(--gold-deep)}
+.railitem.on{background:var(--grad-gold);color:#fff;font-weight:600;box-shadow:0 2px 8px rgba(181,126,21,.28)}
+.railitem .rdot{position:absolute;top:7px;left:50%;margin-left:-2.5px;width:5px;height:5px;border-radius:50%;background:#ab4a26;display:none}
+.railitem.mod .rdot{display:block}
+
+.hright{display:flex;align-items:center;gap:12px;flex:0 0 auto;flex-wrap:nowrap}
+.huser{display:flex;align-items:center;gap:8px;padding:5px 6px 5px 5px;background:var(--surface);
+  border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow-1)}
+.uavatar{width:26px;height:26px;border-radius:8px;background:var(--grad-gold);color:#fff;
+  display:flex;align-items:center;justify-content:center;font-size:12.5px;font-weight:600;flex:0 0 auto}
+.uinfo2{display:flex;flex-direction:column;line-height:1.25;margin-right:2px}
+.uinfo2 b{font-size:12.5px;font-weight:600;color:var(--ink-2)}
+.uinfo2 i{font-size:11px;font-style:normal;color:var(--ink-4)}
+.ubtn2{font-family:inherit;font-size:12.5px;padding:6px 11px;border-radius:8px;cursor:pointer;
+  border:1px solid var(--line);background:#fff;color:var(--ink-2);transition:.15s;white-space:nowrap}
+.ubtn2:hover{border-color:var(--gold);background:var(--gold-bg);color:var(--gold-deep)}
+.ubtn2.danger{color:#a4462a}
+.ubtn2.danger:hover{border-color:#d98b74;background:#fdf0ea;color:#a4462a}
+
+/* ---------- 账号管理 ---------- */
+.urow{padding:12px 0;border-bottom:1px solid var(--line-soft)}
+.urow.off{opacity:.55}
+.uinfo{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}
+.uinfo b{font-weight:600;color:var(--ink);font-size:13.5px}
+.uname{color:var(--ink-4);font-size:12px}
+.uoff{font-size:11px;color:#a4462a;background:#fdf0ea;border-radius:4px;padding:0 6px}
+.ume{font-size:11px;color:var(--gold-deep);background:var(--gold-bg);border-radius:4px;padding:0 6px}
+.uctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
+.urole{font-family:inherit;font-size:12.5px;padding:5px 8px;border:1px solid var(--line);
+  border-radius:7px;background:#fff;color:var(--ink-2);cursor:pointer}
+.ubtn{font-family:inherit;font-size:12.5px;padding:5px 10px;border:1px solid var(--line);
+  border-radius:7px;background:#fff;color:var(--ink-2);cursor:pointer;transition:.15s}
+.ubtn:hover{border-color:var(--gold);background:var(--gold-bg);color:var(--gold-deep)}
+.ubtn.danger:hover{border-color:#d98b74;background:#fdf0ea;color:#a4462a}
+.ubadge{font-size:11.5px;color:var(--ink-4);background:#f6f2e8;border:1px solid var(--line-soft);
+  border-radius:7px;padding:4px 9px;line-height:1.4}
+.umeta{font-size:11.5px;color:var(--ink-4);margin-top:6px}
+.pwinput{width:100%;padding:8px 10px;font-family:inherit;font-size:13.5px;border:1px solid var(--line);
+  border-radius:8px;outline:none;color:var(--ink)}
+.fmfield{margin-bottom:14px}
+.fmlabel{display:block;font-size:12.5px;color:var(--ink-3);margin-bottom:6px}
+.fmreq{color:#c0603c;margin-left:3px}
+.fmhint{font-size:11.5px;color:var(--ink-4);margin-top:5px;line-height:1.6}
+select.pwinput{appearance:none;-webkit-appearance:none;background:#fff
+  url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%2393876f'/%3E%3C/svg%3E")
+  no-repeat right 11px center;padding-right:30px;cursor:pointer}
+.pwinput:focus{border-color:var(--gold);box-shadow:0 0 0 3px rgba(234,185,78,.16)}
+
+/* ---------- 侧面板 ---------- */
+.sidepanel{position:fixed;top:0;right:0;width:430px;max-width:94vw;height:100%;background:var(--surface);
+  border-left:1px solid var(--line);z-index:12;transform:translateX(102%);transition:transform .26s cubic-bezier(.32,.72,.28,1);
+  display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(150,110,20,.13)}
+.sidepanel.on{transform:translateX(0)}
+.sphead{padding:26px 26px 16px;border-bottom:1px solid var(--line);
+  background:linear-gradient(180deg,#fdf7e8 0%,#ffffff 100%)}
+.sphead h4{font-size:16px;font-weight:600;display:flex;align-items:center;gap:9px;letter-spacing:-.1px}
+.sphead .pill{background:var(--grad-gold);color:#fff;font-size:12px;font-weight:600;padding:2.5px 9px;border-radius:999px;
+  box-shadow:0 1px 5px rgba(181,126,21,.25)}
+.spclose{margin-left:auto;cursor:pointer;color:var(--ink-4);font-size:21px;line-height:1;transition:color .15s}
+.spclose:hover{color:var(--gold-deep)}
+.sphead p{color:var(--ink-3);font-size:13px;margin-top:8px;line-height:1.75}
+.spbody{flex:1;overflow-y:auto;padding:4px 26px 20px}
+.spbody::-webkit-scrollbar,.drawer::-webkit-scrollbar,.txprev::-webkit-scrollbar{width:8px}
+.spbody::-webkit-scrollbar-thumb,.drawer::-webkit-scrollbar-thumb,.txprev::-webkit-scrollbar-thumb{background:#e8d9b8;border-radius:4px}
+.spfoot{padding:16px 26px 20px;border-top:1px solid var(--line);display:flex;gap:10px;align-items:center;
+  background:linear-gradient(0deg,#fdf7e8 0%,#ffffff 100%)}
+.spfoot button{font-family:inherit;font-size:14px;border-radius:var(--r-sm);padding:9px 15px;cursor:pointer;
+  border:1px solid var(--line);background:var(--surface);color:var(--ink-2);transition:border-color .15s,color .15s,background .15s}
+.spfoot button:hover{border-color:var(--gold);color:var(--gold-deep);background:var(--gold-bg)}
+.spfoot .primary{flex:1;background:var(--grad-gold);border-color:#b57e15;color:#fff;font-weight:600;
+  box-shadow:0 2px 9px rgba(181,126,21,.26),inset 0 1px 0 rgba(255,255,255,.3)}
+.spfoot .primary:hover{filter:brightness(1.05);border-color:#b57e15;color:#fff}
+
+/* 指标/文案字段 */
+.thgroup{padding:16px 0;border-bottom:1px solid var(--line-soft)}
+.thgroup:last-child{border-bottom:0}
+.thgroup .gt{font-size:14px;font-weight:600;letter-spacing:-.1px;display:flex;align-items:center;gap:7px}
+.thgroup .gt::before{content:"";width:3px;height:11px;border-radius:2px;background:var(--grad-gold)}
+.thgroup .gh{color:var(--ink-3);font-size:12.5px;margin-top:4px;line-height:1.7;padding-left:10px}
+.throw{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:10px}
+.throw.one{grid-template-columns:1fr 1fr}
+.throw.single{grid-template-columns:1fr}
+.frow{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 0}
+.thfield{display:flex;flex-direction:column;gap:4px}
+.thfield label{font-size:12.5px;color:var(--ink-3);letter-spacing:.1px}
+.thfield .inp{display:flex;align-items:center;background:#fdfaf2;border:1px solid var(--line);border-radius:var(--r-sm);padding:0 10px;
+  transition:border-color .15s,background .15s,box-shadow .15s}
+.thfield .inp:focus-within{border-color:var(--gold);background:var(--surface);box-shadow:0 0 0 3px rgba(200,149,44,.13)}
+.thfield input{width:100%;border:0;background:none;outline:none;font-family:inherit;font-size:14.5px;font-weight:500;
+  color:var(--ink);padding:8px 0;font-variant-numeric:tabular-nums}
+.thfield .u{font-size:12.5px;color:var(--ink-4);padding-left:4px}
+.thfield.chg .inp{border-color:var(--gold);background:var(--gold-bg);box-shadow:inset 2px 0 0 var(--gold)}
+.thfield textarea{width:100%;border:1px solid var(--line);border-radius:var(--r-sm);background:#fdfaf2;outline:none;
+  font-family:inherit;font-size:13.5px;line-height:1.8;color:var(--ink);padding:10px 12px;resize:vertical;
+  transition:border-color .15s,background .15s,box-shadow .15s}
+.thfield textarea:focus{border-color:var(--gold);background:var(--surface);box-shadow:0 0 0 3px rgba(200,149,44,.13)}
+.txprev{margin-top:10px;background:#fdf8ec;border:1px solid #efe1c2;border-left:3px solid var(--gold);
+  border-radius:var(--r-sm);padding:14px 16px;font-size:13.5px;line-height:1.95;color:var(--ink-2);
+  white-space:pre-wrap;word-break:break-word;max-height:280px;overflow-y:auto}
+.badvar{background:#fbe0d6;color:#a8402a;border-radius:3px;padding:0 3px;font-weight:600}
+.txbad{display:none;margin-top:10px;background:#fdf0ea;border:1px solid #f0cfc2;color:#a8402a;
+  border-radius:var(--r-sm);padding:9px 12px;font-size:13px;line-height:1.7}
+.thfield label{display:flex;align-items:center;gap:6px}
+.varbtn{margin-left:auto;border:1px solid var(--line);background:#fff;color:var(--gold-deep);
+  font-family:inherit;font-size:12px;line-height:1;padding:3px 7px;border-radius:5px;cursor:pointer;transition:all .15s}
+.varbtn:hover{background:var(--gold-bg);border-color:var(--gold)}
+.varchips{display:none;flex-wrap:wrap;gap:5px;margin-top:7px}
+.varchips.on{display:flex}
+.varchips b{font-weight:400;font-size:12px;line-height:1;padding:4px 7px;border-radius:999px;cursor:pointer;
+  background:#fbf6ea;border:1px solid #ecd9ae;color:#8a6a2f;transition:all .15s}
+.varchips b:hover{background:var(--grad-gold);border-color:#b57e15;color:#fff}
+.varwarn{display:none;color:#a8402a;font-size:12px;line-height:1.6;margin-top:4px}
+.thfield.bad .varwarn{display:block}
+.thfield.bad .inp{border-color:#d98b74;background:#fdf3ee}
+.thfield.bad textarea{border-color:#d98b74;background:#fdf3ee}
+
+/* ---------- 导入 ---------- */
+.drop{display:block;border:1px dashed #e0cda3;border-radius:var(--r-md);padding:30px 16px;text-align:center;
+  color:var(--ink-3);font-size:14px;cursor:pointer;background:#fdfaf2;transition:all .15s}
+.drop:hover,.drop.over{border-color:var(--gold);background:var(--gold-bg);color:var(--gold-deep)}
+.imresult{margin-top:14px;font-size:13.5px}
+.imresult .ok{background:#f2f8f4;border:1px solid #d6e8dc;color:#3f6d55;border-radius:var(--r-sm);padding:11px 13px;line-height:1.9}
+.imresult .bad{background:#fcf0ec;border:1px solid #f1d6cd;color:#9c4530;border-radius:var(--r-sm);padding:11px 13px}
+.history{font-size:13px;color:var(--ink-3)}
+.history .hrow{padding:8px 0;border-bottom:1px solid var(--line-soft);display:flex;gap:10px;align-items:center}
+.history .hmain{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.history .hmeta{white-space:nowrap;color:var(--ink-4);font-size:12px}
+.history .hrow b{color:var(--ink-2);font-weight:500}
+.hbtn{flex:0 0 auto;font-family:inherit;font-size:12px;padding:4px 11px;border-radius:6px;cursor:pointer;
+  border:1px solid var(--line);background:#fff;color:var(--gold-deep);transition:.15s;white-space:nowrap}
+.hbtn:hover{border-color:var(--gold);background:var(--gold-bg)}
+.hbtn:disabled{opacity:.4;cursor:default;border-color:var(--line);background:#fff;color:var(--ink-4)}
+
+/* ---------- 门店详情 ---------- */
+.overlay{position:fixed;inset:0;background:rgba(52,38,10,.30);opacity:0;pointer-events:none;transition:opacity .2s;z-index:9;backdrop-filter:blur(1.5px)}
+.overlay.on{opacity:1;pointer-events:auto}
+.overlay.hi{z-index:11}
+.drawer{position:fixed;top:0;right:-520px;width:500px;max-width:94vw;height:100%;background:var(--surface);
+  border-left:1px solid var(--line);z-index:10;transition:right .26s cubic-bezier(.32,.72,.28,1);overflow-y:auto;
+  box-shadow:-16px 0 48px rgba(150,110,20,.15)}
+.drawer.on{right:0}
+.dwhead{position:sticky;top:0;z-index:2;padding:26px 28px 15px;border-bottom:1px solid var(--line-soft);
+  background:linear-gradient(180deg,#fdf7e8 0%,#ffffff 92%)}
+.dwhead::before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:var(--grad-gold)}
+.dwbody{padding:16px 28px 44px}
+.dwclose{position:absolute;top:24px;right:24px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;
+  border-radius:8px;color:var(--ink-4);font-size:22px;line-height:1;cursor:pointer;transition:background .15s,color .15s}
+.dwclose:hover{background:var(--gold-bg);color:var(--gold-deep)}
+.drawer h2{font-size:18.5px;font-weight:600;display:flex;gap:10px;align-items:center;flex-wrap:wrap;letter-spacing:-.3px;padding-right:34px}
+.drawer .sid{margin:5px 0 0}
+.drawer .sid,.drawer .tag + .tag{margin-left:0}
+.mrow{margin:15px 0}
+.mrow .lab{display:flex;justify-content:space-between;font-size:13px;color:var(--ink-3);letter-spacing:.1px}
+.mrow .lab span:last-child{color:var(--ink-2);font-variant-numeric:tabular-nums;font-weight:500}
+.mrow .track{position:relative;height:6px;background:#f5ecda;border-radius:4px;margin-top:7px}
+.mrow .fill{height:100%;border-radius:4px;transition:width .3s}
+.mrow .mk{position:absolute;top:-3px;width:1.5px;height:12px;background:#c6b48c;opacity:.9}
+/* ---------- 诊断结论(v2)---------- */
+.dxhead{margin:2px 0 16px;padding:13px 15px;border-radius:10px;background:var(--gold-bg);border:1px solid #f0e2c2}
+.dxhead.t-R{background:#fdf4f0;border-color:#f0d5ca}
+.dxhead.t-B{background:#fdf8ee;border-color:#efe0bd}
+.dxhead.t-T{background:#f7f7f4;border-color:#e8e4d8}
+.dxhead.t-OK{background:#f2f7f3;border-color:#d9e8dd}
+.dxhd{font-size:14px;line-height:1.7;color:var(--ink);font-weight:500}
+.dxmeta{margin-top:6px;font-size:12px;color:var(--ink-3)}
+.dxsec{margin-bottom:4px}
+.dxt{font-size:12px;color:var(--ink-3);letter-spacing:.4px;margin:0 0 7px}
+.dxrow{margin:0 0 9px}
+.dxrow .dxact{margin-top:4px;font-size:12.5px;line-height:1.7;color:var(--ok);padding-left:2px}
+.dxrow.small{margin-bottom:6px}
+.dxnone{font-size:13.5px;color:var(--ok);padding:2px 0 10px}
+.dxmore{margin:14px 0 4px;border-top:1px solid var(--line-soft);padding-top:10px}
+.dxmore summary{cursor:pointer;font-size:12px;color:var(--ink-3);outline:none;user-select:none}
+.dxmore summary:hover{color:var(--gold-deep)}
+.dxmore .dxrow{margin-top:9px}
+.flag{margin:9px 0;font-size:14px;display:flex;gap:8px;align-items:flex-start}
+.flag .act{color:var(--ok);font-size:13px;line-height:1.7}
+.copybtn{margin-top:22px;width:100%;background:var(--grad-gold);border:1px solid #b57e15;color:#fff;font-weight:600;
+  border-radius:var(--r-sm);padding:11px;font-size:14px;cursor:pointer;font-family:inherit;letter-spacing:.4px;
+  box-shadow:0 3px 12px rgba(181,126,21,.28),inset 0 1px 0 rgba(255,255,255,.3);transition:filter .15s,box-shadow .15s}
+.copybtn:hover{filter:brightness(1.05);box-shadow:0 6px 18px rgba(181,126,21,.34),inset 0 1px 0 rgba(255,255,255,.3)}
+.dwbrow{display:flex;gap:10px;margin-top:22px}
+.dwbrow .copybtn{margin-top:0;flex:1 1 auto}
+.rpentry{flex:0 0 auto;background:var(--surface);border:1px solid var(--line);color:var(--ink-2);font-weight:600;
+  border-radius:var(--r-sm);padding:11px 18px;font-size:14px;cursor:pointer;font-family:inherit;letter-spacing:.4px;
+  transition:border-color .15s,color .15s,box-shadow .15s}
+.rpentry:hover{border-color:var(--gold);color:var(--gold-deep);box-shadow:var(--shadow-1)}
+.hint{color:var(--ink-4);font-size:12.5px;margin-top:9px;line-height:1.75}
+
+/* ---------- 预览弹窗 ---------- */
+.pvwrap{position:fixed;inset:0;background:rgba(52,38,10,.40);z-index:15;display:none;align-items:center;
+  justify-content:center;padding:32px;backdrop-filter:blur(2px)}
+.pvwrap.on{display:flex}
+.pvcard{background:var(--surface);border-radius:var(--r-lg);width:min(980px,84vw);height:min(760px,84vh);
+  max-height:88vh;display:flex;flex-direction:column;box-shadow:0 28px 70px rgba(80,55,5,.32);overflow:hidden;
+  border:1px solid var(--line)}
+.pvhead{display:flex;align-items:center;gap:8px;padding:20px 28px;font-size:16px;font-weight:600;letter-spacing:-.1px;
+  border-bottom:1px solid var(--line);background:linear-gradient(180deg,#fdf7e8,#fff)}
+.pvhead::before{content:"";width:3px;height:14px;border-radius:2px;background:var(--grad-gold)}
+.pvclose{margin-left:auto;cursor:pointer;color:var(--ink-4);font-size:22px;line-height:1;font-weight:400}
+.pvclose:hover{color:var(--gold-deep)}
+.pvcard textarea{flex:1;border:0;outline:none;resize:none;padding:22px 28px;font-family:inherit;
+  font-size:14.5px;line-height:2.05;color:var(--ink-2);background:var(--surface);min-height:240px}
+.pvfoot{display:flex;gap:10px;padding:16px 28px 20px;border-top:1px solid var(--line);background:linear-gradient(0deg,#fdf7e8,#fff)}
+.pvfoot button{font-family:inherit;font-size:14px;border-radius:var(--r-sm);padding:10px 18px;cursor:pointer;
+  border:1px solid var(--line);background:var(--surface);color:var(--ink-2);transition:border-color .15s,color .15s,background .15s}
+.pvfoot button:hover{border-color:var(--gold);color:var(--gold-deep);background:var(--gold-bg)}
+.pvfoot .primary{flex:1;background:var(--grad-gold);border-color:#b57e15;color:#fff;font-weight:600;
+  box-shadow:0 2px 9px rgba(181,126,21,.26),inset 0 1px 0 rgba(255,255,255,.3)}
+.pvfoot .primary:hover{filter:brightness(1.05);color:#fff;border-color:#b57e15}
+
+.cfwrap{position:fixed;inset:0;background:rgba(52,38,10,.40);z-index:30;display:flex;align-items:center;
+  justify-content:center;padding:24px;backdrop-filter:blur(2px);opacity:0;transition:opacity .18s}
+.cfwrap.on{opacity:1}
+.cfcard{background:var(--surface);border-radius:var(--r-lg);width:min(480px,92vw);
+  box-shadow:0 28px 70px rgba(80,55,5,.32);border:1px solid var(--line);overflow:hidden;
+  transform:translateY(8px);transition:transform .2s}
+.cfwrap.on .cfcard{transform:none}
+.cfhead{display:flex;align-items:center;padding:18px 22px;font-size:15.5px;border-bottom:1px solid var(--line);
+  background:linear-gradient(180deg,#fdf7e8,#fff)}
+.cfhead::before{content:"";width:3px;height:14px;border-radius:2px;background:var(--grad-gold);margin-right:9px}
+.cfhead b{flex:1;font-weight:600}
+.cfbody{padding:18px 22px 8px}
+.cfrow{display:flex;gap:12px;padding:6px 0;font-size:13.5px}
+.cfrow .k{flex:0 0 68px;color:var(--ink-3)}
+.cfrow .v{flex:1;color:var(--ink-2)}
+.cfnote{margin-top:12px;padding:10px 12px;background:#fdfaf2;border:1px solid var(--line-soft);
+  border-radius:var(--r-sm);font-size:12.5px;line-height:1.75;color:var(--ink-3)}
+.cfnote b{color:var(--ink-2);font-weight:600}
+.cffoot{display:flex;gap:10px;padding:16px 22px 20px;justify-content:flex-end}
+.cffoot button{font-family:inherit;font-size:14px;border-radius:var(--r-sm);padding:9px 20px;cursor:pointer;
+  border:1px solid var(--line);background:var(--surface);color:var(--ink-2);transition:.15s}
+.cffoot button:hover{border-color:var(--gold);color:var(--gold-deep);background:var(--gold-bg)}
+.cffoot .primary{background:var(--grad-gold);border-color:#b57e15;color:#fff;font-weight:600;
+  box-shadow:0 2px 9px rgba(181,126,21,.26),inset 0 1px 0 rgba(255,255,255,.3)}
+.cffoot .primary:hover{filter:brightness(1.05);color:#fff}
+
+/* ---------- toast ---------- */
+.toast{position:fixed;left:50%;bottom:34px;transform:translate(-50%,12px);background:#3a2c10;color:#fdf7e8;font-size:14px;
+  padding:10px 20px;border-radius:999px;opacity:0;pointer-events:none;transition:all .24s;z-index:20;
+  box-shadow:0 12px 32px rgba(70,48,5,.30);letter-spacing:.2px;border:1px solid rgba(234,185,78,.28)}
+.toast.on{opacity:1;transform:translate(-50%,0)}
+
+@media(max-width:1100px){.wrap{padding:24px 18px 56px 62px}.kpis{grid-template-columns:repeat(2,1fr)}
+  header{flex-wrap:wrap}.hright{width:100%;justify-content:flex-start}}
+@media(max-width:960px){.charts{grid-template-columns:1fr}}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 34 - 0
store-ops/frontend/vendor/echarts.min.js


+ 43 - 0
store-ops/start.sh

@@ -0,0 +1,43 @@
+#!/bin/bash
+# 门店检测工作台 —— 服务器 / Linux 启动脚本(非交互,适合 systemd、nohup、docker)
+#
+# 用法:
+#   ./start.sh                  # 默认端口 8787,开放局域网
+#   ./start.sh 9000             # 指定端口
+#   ./start.sh --local          # 只允许本机访问(放在 nginx 后面时用)
+#   PORT=9000 ./start.sh        # 也可以用环境变量
+#
+# 建议放在 systemd 里跑,见 deploy/门店检测工作台.service
+
+set -e
+cd "$(dirname "$0")"
+
+PORT="${PORT:-8787}"
+HOST_ARG=()
+for a in "$@"; do
+  case "$a" in
+    --local) HOST_ARG+=("$a") ;;
+    --*) ;;
+    *) PORT="$a" ;;
+  esac
+done
+
+# 找 Python 3.7+
+PY=""
+for c in python3 python; do
+  if command -v "$c" >/dev/null 2>&1 && \
+     "$c" -c 'import sys; sys.exit(0 if sys.version_info >= (3,7) else 1)' 2>/dev/null; then
+    PY="$c"; break
+  fi
+done
+
+if [ -z "$PY" ]; then
+  echo "✗ 没找到 Python 3.7+,请先安装:" >&2
+  echo "  Ubuntu/Debian: sudo apt update && sudo apt install -y python3" >&2
+  echo "  CentOS/RHEL:   sudo yum install -y python3" >&2
+  exit 1
+fi
+
+echo "Python: $($PY --version 2>&1)  ($(command -v $PY))"
+echo "端口:   $PORT"
+exec "$PY" -u backend/server.py --port "$PORT" "${HOST_ARG[@]}"

+ 52 - 0
store-ops/sync/com.yhm.lifesync.plist

@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!--
+  生意经「近30天」数据每日自动同步(每天 01:00)
+  安装:
+    cp com.yhm.lifesync.plist ~/Library/LaunchAgents/
+    launchctl load ~/Library/LaunchAgents/com.yhm.lifesync.plist
+  立即测试一次:
+    launchctl start com.yhm.lifesync
+  卸载:
+    launchctl unload ~/Library/LaunchAgents/com.yhm.lifesync.plist
+  日志:store-ops/data/sync_log/ ,launchd 层日志 /tmp/yhm_lifesync.{out,err}
+-->
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.yhm.lifesync</string>
+
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/bash</string>
+    <string>-lc</string>
+    <string>/bin/bash "/Users/haimian/Documents/deepseek-harness/default-workspace/门店检测工作台/store-ops/sync/run_sync.sh"</string>
+  </array>
+
+  <!-- 每天凌晨 01:00 -->
+  <key>StartCalendarInterval</key>
+  <dict>
+    <key>Hour</key>
+    <integer>1</integer>
+    <key>Minute</key>
+    <integer>0</integer>
+  </dict>
+
+  <!-- launchd 默认 PATH 不含 ~/.local/bin,这里补上,脚本里也有绝对路径兜底 -->
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/Users/haimian/.local/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+    <key>HOME</key>
+    <string>/Users/haimian</string>
+  </dict>
+
+  <key>RunAtLoad</key>
+  <false/>
+
+  <key>StandardOutPath</key>
+  <string>/tmp/yhm_lifesync.out</string>
+  <key>StandardErrorPath</key>
+  <string>/tmp/yhm_lifesync.err</string>
+</dict>
+</plist>

+ 160 - 0
store-ops/sync/life_sync.mjs

@@ -0,0 +1,160 @@
+/**
+ * 生意经「近30天门店数据」自动同步
+ *
+ * 用法(必须用 ego-browser 跑,因为它需要浏览器的登录态):
+ *   ego-browser nodejs store-ops/sync/life_sync.mjs
+ *   ego-browser nodejs store-ops/sync/life_sync.mjs --days 30 --dry-run
+ *
+ * 做的事:
+ *   1. 打开生意经报表集市(复用浏览器里的登录态)
+ *   2. 调 /api/lowcode_api/query 建一个「按统计周期、全部门店、41 个指标」的导出任务
+ *   3. 轮询任务直到生成下载地址
+ *   4. 下载 xlsx,文件名带上统计窗口(导入器据此按「近 N 天快照」入库)
+ *   5. 落到 store-ops/data/inbox/,服务端 20 秒内自动导入
+ */
+const fs = await import("node:fs/promises");
+const path = await import("node:path");
+
+const ARGS = (globalThis.SYNC_ARGS || process.argv.slice(2) || []);
+const opt = (name, def) => {
+  const i = ARGS.indexOf("--" + name);
+  return i >= 0 && ARGS[i + 1] && !ARGS[i + 1].startsWith("--") ? ARGS[i + 1] : def;
+};
+const DAYS = parseInt(opt("days", "30"), 10);
+const DRY = ARGS.includes("--dry-run");
+const POLL_TIMEOUT = parseInt(opt("timeout", "1200"), 10) * 1000;  // 平台排队时间不稳定,放宽到 20 分钟
+
+const STORE_OPS = "/Users/haimian/Documents/deepseek-harness/default-workspace/门店检测工作台/store-ops";
+const INBOX = path.join(STORE_OPS, "data", "inbox");
+
+const ACCT = "7376197379193669659";
+const PAGE_ID = "15352";
+const PROJECT = "shengyijing";
+const IF_CREATE = "0ac0bacd";
+const IF_TASK = "d715cc19";
+const IF_QUOTA = "9e029004";
+
+// 41 个指标(与页面「门店」tab 全选一致)
+const INDICATORS = [
+  "poi_id", "life_account_name", "lbs_city", "enter_poi_cnt", "enter_poi_uv_cnt",
+  "click_poi_project_card_cnt_1d", "click_poi_project_card_uv_cnt_1d",
+  "pay_cert_cnt_1d", "pay_user_cnt_1d", "pay_gmv_1d", "refund_amount_1d",
+  "refund_cert_cnt_1d", "refund_user_cnt_1d", "pay_intention_gmv_1d",
+  "pay_intention_cert_cnt_1d", "pay_intention_user_cnt_1d", "verify_amount_1d",
+  "verify_cert_cnt_1d", "verify_user_cnt_1d", "verify_new_user_cnt_1d",
+  "verify_old_user_cnt_1d", "refund_intention_gmv_1d", "refund_intention_cert_cnt_1d",
+  "refund_intention_user_cnt_1d", "video_cnt_1d", "video_play_cnt_1d", "poi_score",
+  "new_rate_cnt_1d", "positive_rate_cnt_1d", "normal_negative_rate",
+  "all_new_rate_cnt", "all_positive_rate_cnt", "all_normal_negative_rate_cnt",
+  "reply_rate_ratio", "cs_ticket_ratio", "bad_comment_ratio", "account_refund_order_ratio",
+  "visible_checkin_cnt_1d", "favorite_cnt_1d", "rank_text",   // 不再拉「点亮后的投稿数」:数据不可靠
+];
+
+const POI_SELECTOR = JSON.stringify({
+  isSelectAll: true, dimension: 101, type: "struct",
+  structValue: JSON.stringify({
+    ExpandToPoiAccount: true, SearchAllAccountPoiType: 6, SearchAllAccountPoiStatus: 0,
+    Selections: [], PoiName: "", RelationTypes: [1, 12, 5], TagIDList: [],
+    MainCategoryList: {}, SubCategoryList: {}, PoiAccountMarkets: [], PoiAccountMarks: [],
+    PoiOpenStatus: [], PermissionKeyList: ["hermes.data.shengyijing_data_view"],
+    StoreBizTagList: [], SxtSolutionStatusList: [], SxtSolutionPunishStatusList: [],
+    SettleStatusBeforeClaim: [],
+  }),
+});
+
+const pad = (n) => String(n).padStart(2, "0");
+const dstr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+const compact = (s) => s.replace(/-/g, "");
+
+const run = async () => {
+  // ---- 统计窗口:今天往前 DAYS 天 ----
+  const end = new Date();
+  const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
+  const winStart = dstr(start);
+  const winEnd = dstr(end);
+  const now = new Date();
+  const taskName = `同步_${compact(winEnd)}_${pad(now.getHours())}${pad(now.getMinutes())}`;
+
+  console.log(`[1/5] 统计窗口 ${winStart} ~ ${winEnd}(${DAYS} 天)`);
+  console.log(`      任务名 ${taskName}`);
+  if (DRY) { console.log("      --dry-run,不实际建任务"); return; }
+
+  const task = await taskSpace("生意经 每日同步");
+  const page = task.page("p1");
+
+  // ---- 打开页面,拿到登录态上下文 ----
+  await page.goto("https://www.life-data.cn/data-bazaar/my/index", { waitUntil: "load", timeout: 30000 });
+  await page.waitForTimeout(1500);
+  const ok = await page.evaluate(() => !/登录|扫码/.test((document.body.innerText || "").slice(0, 300)));
+  if (!ok) throw new Error("生意经未登录,请先在 ego 浏览器里登录一次");
+  console.log("[2/5] 页面已就绪");
+
+  // ---- 建任务 / 查任务 / 配额 都在页面上下文里发 ----
+  const call = async (iid, dq) => page.evaluate(async ({ iid, dq, ACCT, PAGE_ID, PROJECT }) => {
+    const r = await fetch(
+      `/api/lowcode_api/query?projectKey=${PROJECT}&pageId=${PAGE_ID}&interface_id=${iid}`,
+      { method: "POST", credentials: "include",
+        headers: { accept: "application/json", "content-type": "application/json; charset=utf-8",
+          "x-tt-trace-log": "01", "root-life-account-id": ACCT, "life-account-id": ACCT },
+        body: JSON.stringify({ interface_id: iid, data_query: dq }) });
+    return r.json();
+  }, { iid, dq, ACCT, PAGE_ID, PROJECT });
+
+  const quota = await call(IF_QUOTA, { data_query_type: "data_query",
+    indicators: ["total", "available"], filters: null, group_by: [], extra: {} });
+  const q = (quota.data || [])[0] || {};
+  console.log(`[2/5] 今日剩余建任务次数:${q.available === undefined ? "?" : q.available}`);
+  if (q.available !== undefined && Number(q.available) <= 0) throw new Error("今日建任务次数已用完");
+
+  const created = await call(IF_CREATE, {
+    data_query_type: "data_query",
+    indicators: INDICATORS,
+    filters: { operator: "and", filters: [
+      { field: { name: "date" }, filter_operator: "between", range_values: [compact(winStart), compact(winEnd)] },
+      { field: { name: "file_name" }, filter_operator: "equal", value: taskName },
+      { field: { name: "ignore_zero_rows" }, filter_operator: "equal", value: 1 },
+      { field: { name: "poi_selector_json" }, filter_operator: "equal", value: POI_SELECTOR },
+    ] },
+    group_by: [],
+    order_by: [{ order_field: "day", order_type: "desc" }],
+    extra: {},
+  });
+  if (created.code !== 0) throw new Error("建任务失败:" + (created.msg || JSON.stringify(created).slice(0, 200)));
+  console.log(`[3/5] 任务已提交 ${created.task_id || ""}`);
+
+  // ---- 轮询下载地址 ----
+  const t0 = Date.now();
+  let link = "";
+  while (Date.now() - t0 < POLL_TIMEOUT) {
+    await page.waitForTimeout(8000);
+    const r = await call(IF_TASK, {
+      data_query_type: "data_query",
+      indicators: ["task_id", "task_name", "download_link", "create_time"],
+      filters: { operator: "and", filters: [
+        { field: { name: "query_task_name" }, filter_operator: "equal", value: taskName }] },
+      group_by: [], extra: {},
+    });
+    const row = (r.data || [])[0];
+    if (row && row.download_link) { link = row.download_link; break; }
+    const secs = Math.round((Date.now() - t0) / 1000);
+    console.log(`      …等待生成(${secs}s)`);
+  }
+  if (!link) throw new Error("等待超时,任务未生成下载地址");
+  console.log("[4/5] 下载地址已生成");
+
+  // ---- 下载到收件箱(文件名带窗口,导入时按快照处理)----
+  await fs.mkdir(INBOX, { recursive: true });
+  const fname = `门店数据_${winStart}_${winEnd}.xlsx`;
+  const target = path.join(INBOX, fname);
+  await page.fetch(link, { saveAs: target });
+  const st = await fs.stat(target);
+  console.log(`[5/5] 已下载 ${fname}(${(st.size / 1024).toFixed(0)} KB)`);
+  console.log("      收件箱会在 20 秒内自动导入,导入后周期下拉会出现「近N天 · 截至 " + winEnd.slice(5) + "」");
+};
+
+try {
+  await run();
+} catch (e) {
+  console.error("✗ 同步失败:" + (e && e.message ? e.message : e));
+  process.exitCode = 1;
+}

+ 40 - 0
store-ops/sync/run_sync.sh

@@ -0,0 +1,40 @@
+#!/bin/bash
+# 生意经「近30天」数据同步 —— 供 launchd / cron 调用的入口
+# 用法:./sync/run_sync.sh [--days 30]
+cd "$(dirname "$0")/.." || exit 1
+
+MK="$(pwd)/sync/life_sync.mjs"
+
+# launchd 的 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin,ego-browser 不在里面,
+# 所以显式定位:优先环境变量,其次常见安装位置,最后才用 PATH 查找
+EGO=""
+for c in "${EGO_BIN:-}" "$HOME/.local/bin/ego-browser" "/usr/local/bin/ego-browser" \
+         "/opt/homebrew/bin/ego-browser" "$(command -v ego-browser 2>/dev/null)"; do
+  [ -n "$c" ] && [ -x "$c" ] && { EGO="$c"; break; }
+done
+if [ -z "$EGO" ]; then
+  echo "✗ 找不到 ego-browser,无法同步" | tee -a data/sync_log/FAILED.txt
+  exit 127
+fi
+mkdir -p data/sync_log
+LOG="data/sync_log/$(date +%Y%m%d_%H%M%S).log"
+
+# 只保留最近 60 份日志
+ls -1t data/sync_log/*.log 2>/dev/null | tail -n +61 | xargs -r rm -f
+
+{
+  echo "===== $(date '+%Y-%m-%d %H:%M:%S') 开始同步 ====="
+  "$EGO" nodejs <<EOF
+globalThis.SYNC_ARGS = $(python3 -c "import json,sys; print(json.dumps(sys.argv[1:]))" "$@");
+await import("file://" + encodeURI("$MK"));
+EOF
+  code=$?
+  echo "===== $(date '+%Y-%m-%d %H:%M:%S') 结束,退出码 $code ====="
+  exit $code
+} 2>&1 | tee "$LOG"
+
+# 失败时在项目里留个醒目标记,方便事后发现
+if [ "${PIPESTATUS[0]}" != "0" ]; then
+  echo "$(date '+%Y-%m-%d %H:%M:%S') 同步失败,日志:$LOG" >> data/sync_log/FAILED.txt
+  exit 1
+fi

+ 257 - 0
store-ops/tools/gen_rules_doc.py

@@ -0,0 +1,257 @@
+# -*- coding: utf-8 -*-
+"""把当前生效的检测规则导出成本地文档(DOCX + Markdown)
+用法:python3 tools/gen_rules_doc.py
+"""
+import os, sys, json
+from datetime import datetime
+
+BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.join(BASE, "backend"))
+os.chdir(BASE)
+
+import re
+import rules, db, codesync
+
+
+def clean(a):
+    """去掉动作行首的 ✅/✓,与工作台展示保持一致"""
+    return re.sub(r"^[\s\u2705\u2714\u2611\ufe0f]+", "", str(a or "")).strip()
+
+LAYER = [("traffic", "① 流量层 · 能不能被看到"),
+         ("convert", "② 转化层 · 看到了有没有下单"),
+         ("fulfill", "③ 履约层 · 下单了有没有核销"),
+         ("repute", "④ 口碑层 · 体验好不好")]
+TIER = {"R": "违规红线", "B": "考核未达标", "T": "运营提升", "N": "样本不足"}
+COND = {
+    "TD":   "商责退单率 > 1.27% / 3.18% / 6.35%(预警 / 一般严重 / 非常严重)",
+    "TS":   "投诉率 > 0.11% / 0.28% / 0.56%(三级)",
+    "CP":   "新增评价 ≥10 → 新增口径;否则累计评价 ≥20 → 累计口径;都不足仍判定但最高只到预警线;超 0.66% / 1.66% / 3.31%",
+    "SCORE":"评分 < 4.3(0 分按「未开分」处理,文案与动作单独走)",
+    "REPLY":"新增评价 ≥1 且 评价回复率 < 50%",
+    "RVOL": "新增评价数 < 10",
+    "DEAL0":"访问 > 0 且 门店页成交券数 = 0",
+    "ZERO": "门店页成交券数 > 0 且 核销券数 = 0(履约层内命中此项则不再判「核销率低」)",
+    "HX":   "门店页成交券数 ≥ 5 且 核销率 < 40%",
+    "AMT":  "核销金额 < 10,000 元(独立判定,不受核销率约束)",
+    "VISIT":"访问次数 < 100(含 0 访问)",
+    "VIDEO":"新发布门店关联视频数 < 10",
+    "LIT":  "门店点亮数 < 10 且 访问 > 0",
+    "CVR":  "访问 ≥100 且 成交券数 > 0 且 成交券数 ÷ 访问 < 10%",
+    "PEND": "估算未核销券(门店页成交券数 − 核销券数)≥ 10 张",
+    "RANK0": "上榜信息为空 / 「-」,即好评榜、人气榜均未进入",
+    "RANK_IN": "上榜信息只有「入围XX榜」,没有「第 N 名」",
+}
+TREE = [
+    ("① 有 R 级违规(退单 / 投诉 / 差评)", "平台已在处置或限流", "先止血:处理违规项,再谈增长"),
+    ("② 零访问 / 访问过低", "没有流量进来,后面几步都无从谈起", "先修曝光(点亮 + 素材 + 榜单)"),
+    ("③ 零成交 / 转化率低", "有流量但没下单", "修转化(品项、价格、主图)"),
+    ("④ 成交未核销 / 核销率低 / 核销金额低", "客户买了不到店,或到店不核销", "修履约(集中邀约 + 核销激励)"),
+    ("⑤ 评分不达标 / 回复率低", "口碑拖累自然流量与转化", "修口碑(处理差评 + 专人回复)"),
+    ("⑥ 以上都不命中", "本期达标", "保持动作,关注冲刺目标"),
+]
+OUTPUT = [
+    ("一句话结论", "按诊断树选出的主因生成,如「卡在「履约」:核销率 11%(基准线 40%)」"),
+    ("主因", "诊断树从上往下第一个命中的问题"),
+    ("全部问题", "命中几个列几个,不再截断"),
+    ("整改动作", "每个问题一条对应动作,去重后输出"),
+]
+
+
+def collect():
+    T = db.get_thresholds()
+    X = db.get_texts()
+    period = db.latest_period()
+    rows = db.aggregate_period(period)
+    hits, sample = {}, {}
+    for m in rows:
+        d = rules.diagnose(m, T, X)
+        for p in d["problems"]:
+            hits[p["key"]] = hits.get(p["key"], 0) + 1
+            sample.setdefault(p["key"], p)
+    groups = []
+    for key, name in LAYER:
+        items = []
+        for rk, tier, layer in rules.RULES:
+            if layer != key:
+                continue
+            p = sample.get(rk)
+            items.append({
+                "id": rk, "tier": tier, "name": rules.RULE_CAT.get(rk, rk),
+                "cond": COND.get(rk, ""),
+                "problem": (p["text"] if p else (X.get("t_" + rk) or "")),
+                "action": clean(p["action"] if p else (X.get("a_" + rk) or "")),
+                "hits": hits.get(rk, 0),
+            })
+        groups.append((name, items))
+    params = [(k, T.get(k), codesync.get_factory().get(k)) for k in rules.DEFAULT_THRESHOLDS]
+    return {"period": period, "stores": len(rows), "groups": groups, "params": params,
+            "tree": TREE, "output": OUTPUT}
+
+
+def build_md(d):
+    L = ["# 门店检测工作台 · 检测规则说明", "",
+         "> 数据周期 %s · %d 家门店 · 导出时间 %s" %
+         (d["period"], d["stores"], datetime.now().strftime("%Y-%m-%d %H:%M")), ""]
+    L += ["## 一、核心口径", "",
+          "- 核销率 = 核销券数 ÷ **门店页成交券数**", "- 待核销 = 门店页成交券数 − 核销券数(估算,未扣退款)",
+          "- 差评率:新增评价 ≥10 用新增口径;否则累计 ≥20 用累计口径;都不足仍判定但最高只到预警线",
+          "- 评分 0 分 = 未开分,仍计入「评分不达标」", "- 评价回复率:只在有新增评价时判定",
+          "- **四层并行**:流量 / 转化 / 履约 / 口碑各自独立判定、互不压制;只有履约层内部「成交未核销 → 核销率低」按根因去重",
+          ""]
+    L += ["## 二、规则明细", ""]
+    for name, items in d["groups"]:
+        L += ["### " + name, "", "| ID | 档位 | 指标 | 判定条件 | 问题文案 | 整改动作 | 命中 |",
+              "|---|---|---|---|---|---|---|"]
+        for it in items:
+            L.append("| %s | %s | %s | %s | %s | %s | %d 家 |" %
+                     (it["id"], TIER.get(it["tier"], it["tier"]), it["name"], it["cond"],
+                      it["problem"], it["action"].replace("\n", "<br>"), it["hits"]))
+        L.append("")
+    L += ["## 三、可调阈值", "", "| 参数 | 当前值 | 出厂值 |", "|---|---|---|"]
+    for k, cur, fac in d["params"]:
+        L.append("| %s | %s | %s |" % (k, cur, fac))
+    L += ["", "## 四、诊断树(主因判定顺序)", "", "| 顺序 | 命中条件 | 含义 | 处理方向 |", "|---|---|---|---|"]
+    for i, (a, b, c) in enumerate(d["tree"], 1):
+        L.append("| %d | %s | %s | %s |" % (i, a, b, c))
+    L += ["", "## 五、输出结构", "", "| 字段 | 说明 |", "|---|---|"]
+    for a, b in d["output"]:
+        L.append("| %s | %s |" % (a, b))
+    return "\n".join(L)
+
+
+def build_docx(d, path):
+    from docx import Document
+    from docx.shared import Pt, Cm, RGBColor
+    from docx.enum.text import WD_ALIGN_PARAGRAPH
+    from docx.oxml.ns import qn
+
+    from docx.enum.section import WD_ORIENT
+    from docx.oxml import OxmlElement
+
+    doc = Document()
+    sec = doc.sections[0]
+    # 横向 A4:规则表列多,竖版会把「整改动作」挤成竖条
+    sec.orientation = WD_ORIENT.LANDSCAPE
+    sec.page_width, sec.page_height = Cm(29.7), Cm(21.0)
+    sec.left_margin = sec.right_margin = Cm(1.5)
+    sec.top_margin = sec.bottom_margin = Cm(1.5)
+
+    st = doc.styles["Normal"]
+    st.font.name = "PingFang SC"
+    st.font.size = Pt(10)
+    st._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
+    st.paragraph_format.space_after = Pt(4)
+    st.paragraph_format.line_spacing = 1.35
+
+    def cn(run, size=None, bold=None, color=None):
+        run.font.name = "PingFang SC"
+        run._element.rPr.rFonts.set(qn("w:eastAsia"), "PingFang SC")
+        if size: run.font.size = Pt(size)
+        if bold is not None: run.font.bold = bold
+        if color: run.font.color.rgb = RGBColor(*color)
+
+    t = doc.add_paragraph()
+    cn(t.add_run("门店检测工作台 · 检测规则说明"), 18, True, (0x21, 0x1a, 0x10))
+    t.alignment = WD_ALIGN_PARAGRAPH.LEFT
+    meta = doc.add_paragraph()
+    cn(meta.add_run("数据周期 %s · %d 家门店 · 导出时间 %s" %
+                    (d["period"], d["stores"], datetime.now().strftime("%Y-%m-%d %H:%M"))), 9, False, (0x93, 0x87, 0x6f))
+
+    def h(text, size=13):
+        p = doc.add_paragraph()
+        p.paragraph_format.space_before = Pt(14)
+        p.paragraph_format.space_after = Pt(6)
+        cn(p.add_run(text), size, True, (0x21, 0x1a, 0x10))
+        return p
+
+    def table(headers, rows, widths):
+        tb = doc.add_table(rows=1, cols=len(headers))
+        tb.style = "Table Grid"
+        tb.autofit = False
+        # 固定布局,否则 Word 会按内容重新分配列宽
+        tblPr = tb._tbl.tblPr
+        layout = OxmlElement("w:tblLayout")
+        layout.set(qn("w:type"), "fixed")
+        tblPr.append(layout)
+        tblW = OxmlElement("w:tblW")
+        tblW.set(qn("w:type"), "dxa")
+        tblW.set(qn("w:w"), str(int(sum(widths) * 567)))
+        tblPr.append(tblW)
+        for i, w in enumerate(widths):
+            tb.columns[i].width = Cm(w)
+            for cell in tb.columns[i].cells:
+                cell.width = Cm(w)
+        for i, hd in enumerate(headers):
+            c = tb.rows[0].cells[i]
+            c.text = ""
+            cn(c.paragraphs[0].add_run(hd), 9, True)
+        for row in rows:
+            cells = tb.add_row().cells
+            for i, v in enumerate(row):
+                cells[i].width = Cm(widths[i])
+                cells[i].text = ""
+                p = cells[i].paragraphs[0]
+                p.paragraph_format.space_after = Pt(2)
+                cn(p.add_run(str(v)), 8.5)
+        return tb
+
+    # 一、口径
+    h("一、核心口径")
+    for line in [
+        "核销率 = 核销券数 ÷ 门店页成交券数(分母为 0 时不判定)",
+        "待核销 = 门店页成交券数 − 核销券数(估算值,未扣退款,仅作邀约优先级参考)",
+        "差评率:新增评价 ≥10 用新增口径;否则累计评价 ≥20 用累计口径;都不足仍判定,但最高只到预警线",
+        "评分:取周期内最近一次有值;0 分视为「未开分」,仍计入「评分不达标」",
+        "评价回复率:只在有新增评价时判定(没有新评价就没有回复义务)",
+        "四层并行:流量 / 转化 / 履约 / 口碑各自独立判定、互不压制;只有履约层内部「成交未核销 → 核销率低」按根因去重",
+    ]:
+        p = doc.add_paragraph(style="List Bullet")
+        cn(p.add_run(line), 9.5)
+
+    # 二、规则明细
+    h("二、规则明细(共 %d 条)" % sum(len(i) for _, i in d["groups"]))
+    p0 = doc.add_paragraph()
+    cn(p0.add_run("说明:「问题文案」列给的是按当前数据渲染出的真实示例,实际输出时会替换成各门店自己的数值。"),
+       8.5, False, (0x93, 0x87, 0x6f))
+    for name, items in d["groups"]:
+        p = doc.add_paragraph()
+        p.paragraph_format.space_before = Pt(10)
+        cn(p.add_run(name), 10.5, True, (0x8a, 0x6a, 0x2f))
+        table(["ID", "档位", "指标", "判定条件", "问题文案", "整改动作", "命中"],
+              [[it["id"], TIER.get(it["tier"], it["tier"]), it["name"], it["cond"],
+                it["problem"], it["action"].replace("\n", " / "), "%d 家" % it["hits"]]
+               for it in items],
+              [1.4, 1.9, 2.4, 7.0, 5.4, 6.2, 1.4])
+
+    # 三、阈值
+    h("三、可调阈值(%d 项)" % len(d["params"]))
+    doc.add_paragraph().add_run()
+    p = doc.add_paragraph()
+    cn(p.add_run("在【指标配置】面板改数字即可,保存时会同步写回代码默认值。"), 9, False, (0x93, 0x87, 0x6f))
+    table(["参数", "当前值", "出厂值"],
+          [[k, str(cur), str(fac)] for k, cur, fac in d["params"]],
+          [9.0, 4.0, 4.0])
+
+    # 四、诊断树
+    h("四、诊断树(主因判定顺序)")
+    table(["顺序", "命中条件", "含义", "处理方向"],
+          [[str(i), a, b, c] for i, (a, b, c) in enumerate(d["tree"], 1)],
+          [1.2, 9.5, 7.0, 8.0])
+
+    # 五、输出
+    h("五、每家门店的输出结构")
+    table(["字段", "说明"], [[a, b] for a, b in d["output"]], [3.2, 14.2])
+
+    doc.save(path)
+    return path
+
+
+if __name__ == "__main__":
+    d = collect()
+    out = os.path.join(os.path.dirname(BASE), "检测规则说明_%s" % datetime.now().strftime("%Y%m%d"))
+    md = out + ".md"
+    open(md, "w", encoding="utf-8").write(build_md(d))
+    print(md)
+    docx_path = out + ".docx"
+    build_docx(d, docx_path)
+    print(docx_path)

+ 17 - 0
store-ops/启动工作台.bat

@@ -0,0 +1,17 @@
+@echo off
+chcp 936 >nul
+cd /d %~dp0
+set LANIP=
+for /f "tokens=*" %%i in ('python -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('8.8.8.8',80));print(s.getsockname()[0])" 2^>nul') do set LANIP=%%i
+echo ==============================================================
+echo   门店检测工作台
+echo   本机访问  : http://127.0.0.1:8787
+if defined LANIP echo   局域网访问: http://%LANIP%:8787   ^(其他电脑用这个^)
+echo.
+echo   其他电脑打不开时,双击「开放局域网访问.bat」放行防火墙
+echo   关闭此窗口即停止服务
+echo ==============================================================
+echo.
+start "" http://127.0.0.1:8787
+python backend\server.py --port 8787
+pause

+ 64 - 0
store-ops/启动工作台.command

@@ -0,0 +1,64 @@
+#!/bin/bash
+# 门店检测工作台 —— macOS / Linux 启动器
+# 用法:
+#   Finder 里双击本文件即可启动
+#   终端:./启动工作台.command [端口] [--local] [--no-open]
+#   端口默认 8787
+#   --local    只允许本机访问(默认开放给同一局域网)
+#   --no-open  启动后不自动打开浏览器
+
+cd "$(dirname "$0")" || exit 1
+
+# ---- 找 Python 3.7+ ----
+PY=""
+for c in python3 python; do
+  if command -v "$c" >/dev/null 2>&1; then
+    if "$c" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 7) else 1)' >/dev/null 2>&1; then
+      PY="$c"
+      break
+    fi
+  fi
+done
+
+if [ -z "$PY" ]; then
+  echo "✗ 没找到 Python 3.7 或更高版本。"
+  echo ""
+  echo "  安装方式(任选一种):"
+  echo "    1) 到 https://www.python.org/downloads/ 下载安装包"
+  echo "    2) 装了 Homebrew 的话:brew install python"
+  echo ""
+  read -r -p "按回车关闭窗口…" _
+  exit 1
+fi
+
+# ---- 解析参数:端口 + 透传选项 ----
+PORT=8787
+PASSTHRU=()
+OPEN=1
+for a in "$@"; do
+  case "$a" in
+    --no-open) OPEN=0 ;;
+    --local)   PASSTHRU+=("$a") ;;
+    --*)       ;;
+    *)         PORT="$a" ;;
+  esac
+done
+
+if [ "$OPEN" = "1" ]; then
+  PASSTHRU+=("--open")
+fi
+
+# ---- 端口占用检查 ----
+if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
+  echo "✗ 端口 $PORT 已被占用,可能工作台已经在运行。"
+  echo "  先试试打开:http://127.0.0.1:$PORT"
+  echo "  要换端口:./启动工作台.command 9000"
+  echo ""
+  read -r -p "按回车关闭窗口…" _
+  exit 1
+fi
+
+echo "Python:$("$PY" --version 2>&1)  ($(command -v "$PY"))"
+echo ""
+
+exec "$PY" -u backend/server.py --port "$PORT" "${PASSTHRU[@]}"

+ 32 - 0
store-ops/开放局域网访问.bat

@@ -0,0 +1,32 @@
+@echo off
+chcp 936 >nul
+title 开放局域网访问 - 门店检测工作台
+
+net session >nul 2>&1
+if %errorLevel% neq 0 (
+  echo 需要管理员权限,正在请求提权...
+  powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -Verb RunAs"
+  exit /b
+)
+
+echo ==============================================================
+echo   为「门店检测工作台」放行 TCP 8787 端口
+echo   仅在你自己的办公网络内使用,不要在公共 WiFi 上开放
+echo ==============================================================
+echo.
+
+netsh advfirewall firewall delete rule name="门店检测工作台 8787" >nul 2>&1
+netsh advfirewall firewall add rule name="门店检测工作台 8787" dir=in action=allow protocol=TCP localport=8787 profile=any
+
+if %errorLevel%==0 (
+  echo.
+  echo 已放行 8787 端口,同一局域网的其他电脑现在可以访问了。
+  echo 访问地址见启动窗口里的「局域网访问」一行。
+) else (
+  echo.
+  echo 自动放行失败。请手动操作:
+  echo   Windows 安全中心 - 防火墙和网络保护 - 高级设置
+  echo   入站规则 - 新建规则 - 端口 - TCP - 8787 - 允许连接
+)
+echo.
+pause

+ 51 - 0
对标试点/01_对标店清单_模板.csv

@@ -0,0 +1,51 @@
+本店编码,本店名称,本店POI链接,商圈,竞对序号,竞对门店名,竞对POI链接,对标理由
+C001,C001门店,,,,1,,,同商圈最近
+C001,C001门店,,,,2,,,同商圈最近
+C001,C001门店,,,,3,,,同商圈最近
+C001,C001门店,,,,4,,,同商圈最近
+C001,C001门店,,,,5,,,同商圈最近
+C002,C002门店,,,,1,,,同商圈最近
+C002,C002门店,,,,2,,,同商圈最近
+C002,C002门店,,,,3,,,同商圈最近
+C002,C002门店,,,,4,,,同商圈最近
+C002,C002门店,,,,5,,,同商圈最近
+C003,C003门店,,,,1,,,同商圈最近
+C003,C003门店,,,,2,,,同商圈最近
+C003,C003门店,,,,3,,,同商圈最近
+C003,C003门店,,,,4,,,同商圈最近
+C003,C003门店,,,,5,,,同商圈最近
+C004,C004门店,,,,1,,,同商圈最近
+C004,C004门店,,,,2,,,同商圈最近
+C004,C004门店,,,,3,,,同商圈最近
+C004,C004门店,,,,4,,,同商圈最近
+C004,C004门店,,,,5,,,同商圈最近
+C005,C005门店,,,,1,,,同商圈最近
+C005,C005门店,,,,2,,,同商圈最近
+C005,C005门店,,,,3,,,同商圈最近
+C005,C005门店,,,,4,,,同商圈最近
+C005,C005门店,,,,5,,,同商圈最近
+C006,C006门店,,,,1,,,同商圈最近
+C006,C006门店,,,,2,,,同商圈最近
+C006,C006门店,,,,3,,,同商圈最近
+C006,C006门店,,,,4,,,同商圈最近
+C006,C006门店,,,,5,,,同商圈最近
+C007,C007门店,,,,1,,,同商圈最近
+C007,C007门店,,,,2,,,同商圈最近
+C007,C007门店,,,,3,,,同商圈最近
+C007,C007门店,,,,4,,,同商圈最近
+C007,C007门店,,,,5,,,同商圈最近
+C008,C008门店,,,,1,,,同商圈最近
+C008,C008门店,,,,2,,,同商圈最近
+C008,C008门店,,,,3,,,同商圈最近
+C008,C008门店,,,,4,,,同商圈最近
+C008,C008门店,,,,5,,,同商圈最近
+C009,C009门店,,,,1,,,同商圈最近
+C009,C009门店,,,,2,,,同商圈最近
+C009,C009门店,,,,3,,,同商圈最近
+C009,C009门店,,,,4,,,同商圈最近
+C009,C009门店,,,,5,,,同商圈最近
+C010,C010门店,,,,1,,,同商圈最近
+C010,C010门店,,,,2,,,同商圈最近
+C010,C010门店,,,,3,,,同商圈最近
+C010,C010门店,,,,4,,,同商圈最近
+C010,C010门店,,,,5,,,同商圈最近

+ 143 - 0
对标试点/02_采集脚本/collect_douyin.py

@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""
+抖音门店公开信息采集器 v0.1
+不装任何第三方库,直接调用本机 Chrome 无头模式,读取渲染后的页面。
+只采集前台公开可见的信息(评分、评价数、团购商品与已售数),不登录、不破解、不采个人信息。
+
+用法(在 对标试点 目录下执行):
+    python 02_采集脚本/collect_douyin.py --dump
+        读取 01_对标店清单.csv,逐个打开门店链接,把渲染后的页面存到 02_采集脚本/dump/
+        每页间隔约 4 秒,控制频率
+
+    python 02_采集脚本/collect_douyin.py --parse
+        解析 dump/ 里已保存的页面,生成 03_分析/采集快照_<日期>.csv
+        注意: 解析规则需要先用 1-2 个真实页面校准,第一次跑请只放 1-2 条链接
+"""
+import csv
+import glob
+import hashlib
+import os
+import re
+import subprocess
+import sys
+import time
+from datetime import date
+
+BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DUMP_DIR = os.path.join(BASE, '02_采集脚本', 'dump')
+LIST_CSV = os.path.join(BASE, '01_对标店清单.csv')
+SNAP_DIR = os.path.join(BASE, '03_分析')
+
+CHROME_CANDIDATES = [
+    r'C:\Program Files\Google\Chrome\Application\chrome.exe',
+    r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
+]
+CHROME = next((p for p in CHROME_CANDIDATES if os.path.exists(p)), None)
+
+
+def load_links():
+    """从清单里取出所有不重复的门店链接"""
+    if not os.path.exists(LIST_CSV):
+        sys.exit('找不到 01_对标店清单.csv')
+    seen, out = set(), []
+    with open(LIST_CSV, encoding='utf-8-sig') as f:
+        for r in csv.DictReader(f):
+            for key in ('本店POI链接', '竞对POI链接'):
+                link = (r.get(key) or '').strip()
+                if link and link not in seen:
+                    seen.add(link)
+                    out.append((r.get('本店编码', ''), r.get('竞对门店名', ''), link))
+    return out
+
+
+def dump_pages():
+    if not CHROME:
+        sys.exit('本机没有找到 Chrome / Edge,请确认安装路径')
+    os.makedirs(DUMP_DIR, exist_ok=True)
+    links = load_links()
+    print(f'共 {len(links)} 个待采集链接')
+    for i, (code, name, link) in enumerate(links, 1):
+        fp = os.path.join(DUMP_DIR, f'{code}_{hashlib.md5(link.encode()).hexdigest()[:8]}.html')
+        if os.path.exists(fp) and os.path.getsize(fp) > 50000:
+            print(f'[{i}/{len(links)}] 已存在,跳过 {name or code}')
+            continue
+        print(f'[{i}/{len(links)}] {name or code} {link}')
+        try:
+            subprocess.run([CHROME, '--headless=new', '--disable-gpu',
+                            '--window-size=1200,2400', '--virtual-time-budget=8000',
+                            '--dump-dom', link], capture_output=True, timeout=90)
+            # dump-dom 输出到 stdout,这里重跑一次拿文本
+            res = subprocess.run([CHROME, '--headless=new', '--disable-gpu',
+                                  '--window-size=1200,2400', '--virtual-time-budget=8000',
+                                  '--dump-dom', link], capture_output=True, timeout=90)
+            html = (res.stdout or b'').decode('utf-8', 'ignore')
+            if len(html) < 20000:
+                print('    页面内容过短,可能需要登录或被风控,请人工核对这个链接')
+            with open(fp, 'w', encoding='utf-8') as f:
+                f.write(html)
+        except subprocess.TimeoutExpired:
+            print('    超时,跳过')
+        time.sleep(4)
+
+
+def num_cn(s):
+    """把 '1.2万' '1000+' 转成数字,解析不了返回 None"""
+    s = s.replace('+', '').replace(',', '').strip()
+    m = re.match(r'^([\d.]+)(万|w)?$', s)
+    if not m:
+        return None
+    v = float(m.group(1))
+    if m.group(2):
+        v *= 10000
+    return v
+
+
+def parse_page(html):
+    """首次校准前的草稿规则。跑一次真实 dump 后按实际页面结构重写这里。"""
+    items = []
+    rating = None
+    m = re.search(r'(\d\.\d)\s*分', html)
+    if m:
+        rating = float(m.group(1))
+    reviews = None
+    m = re.search(r'(\d+)\s*条评价', html)
+    if m:
+        reviews = int(m.group(1))
+    # 团购商品: 匹配 "商品名 ... 已售 xxx" 的松散结构,实际结构以真实 dump 为准
+    for blk in re.findall(r'<div[^>]*>([^<>]{2,30})</div>.{0,600}?已售\s*([\d.万亿w+]*)', html, re.S):
+        name, sold = blk[0].strip(), num_cn(blk[1] or '')
+        items.append((name, sold))
+    return rating, reviews, items
+
+
+def parse_dumps():
+    today = date.today().isoformat()
+    files = sorted(glob.glob(os.path.join(DUMP_DIR, '*.html')))
+    if not files:
+        sys.exit('dump/ 目录为空,先执行 --dump')
+    out = os.path.join(SNAP_DIR, f'采集快照_{today.replace("-", "")}.csv')
+    with open(out, 'w', encoding='utf-8-sig', newline='') as f:
+        w = csv.writer(f)
+        w.writerow(['采集日期', '门店编码', '门店名', '门店类型', 'POI链接',
+                    '商品名', '原价', '团购价', '已售', '评分', '评价数'])
+        for fp in files:
+            stem = os.path.splitext(os.path.basename(fp))[0]
+            code = stem.split('_')[0]
+            with open(fp, encoding='utf-8') as fh:
+                html = fh.read()
+            rating, reviews, items = parse_page(html)
+            print(f'{stem}: 评分={rating} 评价={reviews} 商品={len(items)}')
+            w.writerow([today, code, stem, '本店', '', '', '', '', '', rating or '', reviews or ''])
+            for name, sold in items:
+                w.writerow([today, code, stem, '本店', '', name, '', '', sold or '', rating or '', reviews or ''])
+    print(f'快照已写入: {out}')
+    print('提示: 字段解析规则是草稿,请人工抽查几行,再决定是否重写 parse_page()')
+
+
+if __name__ == '__main__':
+    if '--dump' in sys.argv:
+        dump_pages()
+    elif '--parse' in sys.argv:
+        parse_dumps()
+    else:
+        print(__doc__)

+ 663 - 0
对标试点/03_分析/build_dashboard.py

@@ -0,0 +1,663 @@
+# -*- coding: utf-8 -*-
+"""
+门店数据工作台生成器 —— 把月度检测数据渲染成单文件交互式 HTML 工作台
+输入: 门店数据_<月份>.csv (与 detect.py 同源)
+输出: 04_输出/门店工作台_<月份>.html
+
+v3 变更:
+- 右侧菜单栏「考核指标」:展示全部阈值默认值,可自定义修改
+- 阈值改动后前端按 detect.py 同一套规则重算全量门店(KPI/图表/表格/详情同步)
+- 自定义阈值存 localStorage,刷新不丢;可一键恢复官方默认
+- 新增「导出清单」:导出当前筛选结果 CSV
+
+用法: python build_dashboard.py [--month 2026-10]
+"""
+import csv
+import json
+import os
+import sys
+
+from detect import detect, THRESH
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(os.path.dirname(BASE), '04_输出')
+
+TEMPLATE = r"""<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>门店数据工作台 __MONTH__</title>
+<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:#f6f4ef;color:#33333d;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.6}
+.wrap{max-width:1280px;margin:0 auto;padding:28px 62px 60px 32px}
+header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+h1{font-size:21px;font-weight:700;color:#33333d}
+.brand{background:#ffe9b3;color:#8a6116;font-weight:600;font-size:12px;padding:5px 14px;border-radius:999px;letter-spacing:1px}
+.sub{color:#8f8d85;font-size:12px}
+.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:20px 0 18px}
+.kpi{background:#fff;border:1px solid #e9e6dd;border-radius:16px;padding:16px 18px;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(60,50,30,.05)}
+.kpi:hover{border-color:#c8cfe0;transform:translateY(-1px)}
+.kpi.on{border-color:#8fb0ea;background:#eef3fc}
+.kpi .v{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap;color:#33333d}
+.kpi .l{color:#8f8d85;font-size:12px;margin-top:2px}
+.kpi.hot .v{color:#e05a48}
+.kpi.hot.on{border-color:#efb3a8;background:#fdf1ee}
+.charts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:18px}
+.panel{background:#fff;border:1px solid #e9e6dd;border-radius:16px;padding:16px 18px;box-shadow:0 1px 3px rgba(60,50,30,.05)}
+.panel h3{font-size:13px;font-weight:600;color:#7c7a72;margin-bottom:6px}
+.chart{height:230px}
+.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
+.bar input,.bar select{background:#fff;border:1px solid #e0ddd4;color:#33333d;border-radius:10px;padding:8px 12px;font-size:13px;outline:none}
+.bar input:focus,.bar select:focus{border-color:#8fb0ea}
+.bar input{width:220px}
+.bar select{max-width:170px}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th{color:#8f8d85;font-weight:500;font-size:12px;padding:9px 10px;border-bottom:1px solid #e9e6dd;text-align:left;white-space:nowrap;cursor:pointer;user-select:none}
+th.num,td.num{text-align:right;font-variant-numeric:tabular-nums}
+td{padding:10px;border-bottom:1px solid #f1efe8;vertical-align:middle}
+tr.row:hover td{background:#faf8f3;cursor:pointer}
+.nm{font-weight:500}.sid{color:#a5a29a;font-size:11px}
+.tag{display:inline-block;padding:1px 8px;border-radius:6px;font-size:11px;margin:1px 4px 1px 0;white-space:nowrap}
+.t-R3{background:#fde2dd;color:#d6442f}
+.t-R2{background:#fdeedd;color:#d0762c}
+.t-R1{background:#fdf5d7;color:#a8821a}
+.t-B{background:#eef3fc;color:#4a72c4}
+.t-T{background:#f0efea;color:#8f8d85}
+.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
+.pager{display:flex;gap:10px;align-items:center;justify-content:flex-end;color:#8f8d85;font-size:12px;padding:12px 4px}
+.pager button{background:#fff;border:1px solid #e0ddd4;color:#33333d;border-radius:10px;padding:5px 14px;cursor:pointer}
+.pager button:hover{border-color:#8fb0ea}
+.pager button:disabled{opacity:.35;cursor:default}
+
+/* ---- 右侧菜单栏 ---- */
+.rail{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:8;display:flex;flex-direction:column;gap:6px;
+      background:#fff;border:1px solid #e9e6dd;border-right:0;border-radius:16px 0 0 16px;padding:10px 7px;
+      box-shadow:-2px 2px 10px rgba(60,50,30,.06)}
+.railitem{position:relative;background:none;border:0;cursor:pointer;font-family:inherit;font-size:13px;color:#6b695f;
+          writing-mode:vertical-rl;letter-spacing:3px;padding:14px 5px;border-radius:10px;transition:all .15s}
+.railitem:hover{background:#f7f5f0;color:#33333d}
+.railitem.on{background:#eef3fc;color:#4a72c4;font-weight:600}
+.railitem .rdot{position:absolute;top:6px;left:50%;margin-left:-3px;width:6px;height:6px;border-radius:50%;background:#e05a48;display:none}
+.railitem.mod .rdot{display:block}
+
+/* ---- 考核指标面板 ---- */
+.thpanel{position:fixed;top:0;right:0;width:410px;max-width:94vw;height:100%;background:#fff;border-left:1px solid #e9e6dd;
+         z-index:12;transform:translateX(102%);transition:transform .24s;display:flex;flex-direction:column;box-shadow:-6px 0 24px rgba(60,50,30,.10)}
+.thpanel.on{transform:translateX(0)}
+.thhead{padding:22px 22px 14px;border-bottom:1px solid #f1efe8}
+.thhead h4{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
+.thhead .pill{background:#fff4dc;color:#9a6d14;font-size:11px;font-weight:500;padding:2px 8px;border-radius:999px}
+.thhead p{color:#a5a29a;font-size:11.5px;margin-top:6px;line-height:1.7}
+.thbody{flex:1;overflow-y:auto;padding:6px 22px 16px}
+.thgroup{padding:14px 0;border-bottom:1px dashed #f1efe8}
+.thgroup:last-child{border-bottom:0}
+.thgroup .gt{font-size:13px;font-weight:600;color:#33333d;display:flex;align-items:center;justify-content:space-between}
+.thgroup .gh{color:#a5a29a;font-size:11px;margin-top:2px}
+.throw{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:9px}
+.throw.one{grid-template-columns:1fr 1fr}
+.throw.single{grid-template-columns:1fr}
+.thfield{display:flex;flex-direction:column;gap:3px}
+.thfield label{font-size:11px;color:#8f8d85}
+.thfield .inp{display:flex;align-items:center;background:#fbfaf7;border:1px solid #e6e3da;border-radius:10px;padding:0 9px;transition:border-color .15s}
+.thfield .inp:focus-within{border-color:#8fb0ea;background:#fff}
+.thfield input{width:100%;border:0;background:none;outline:none;font-family:inherit;font-size:13.5px;font-weight:600;
+               color:#33333d;padding:8px 0;font-variant-numeric:tabular-nums}
+.thfield .u{font-size:11px;color:#a5a29a;padding-left:4px}
+.thfield.chg .inp{border-color:#f0b45c;background:#fffaf0}
+.thfoot{padding:14px 22px 12px;border-top:1px solid #f1efe8;display:flex;gap:10px;align-items:center}
+.thfoot button{font-family:inherit;font-size:13px;border-radius:12px;padding:10px 16px;cursor:pointer;border:1px solid #e0ddd4;background:#fff;color:#33333d}
+.thfoot button:hover{border-color:#8fb0ea}
+.thfoot .primary{flex:1;background:#5b87e8;border-color:#5b87e8;color:#fff;font-weight:600}
+.thfoot .primary:hover{background:#4a76d8}
+.thnote{color:#a5a29a;font-size:11px;padding:0 22px 18px;line-height:1.7}
+
+/* ---- 门店详情抽屉 ---- */
+.overlay{position:fixed;inset:0;background:rgba(40,35,25,.28);opacity:0;pointer-events:none;transition:opacity .2s;z-index:9}
+.overlay.on{opacity:1;pointer-events:auto}
+.overlay.hi{z-index:11}
+.drawer{position:fixed;top:0;right:-520px;width:500px;max-width:94vw;height:100%;background:#fff;border-left:1px solid #e9e6dd;
+        z-index:10;transition:right .22s;overflow-y:auto;padding:26px 26px 40px}
+.drawer.on{right:0}
+.drawer h2{font-size:17px;font-weight:700;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.drawer .sid{margin:4px 0 14px}
+.mrow{margin:13px 0}
+.mrow .lab{display:flex;justify-content:space-between;font-size:12px;color:#7c7a72}
+.mrow .track{position:relative;height:8px;background:#efede6;border-radius:5px;margin-top:5px}
+.mrow .fill{height:100%;border-radius:5px}
+.mrow .mk{position:absolute;top:-3px;width:2px;height:14px;background:#b8b5ac;opacity:.8}
+.flag{margin:8px 0;font-size:13px}
+.flag .act{color:#3f9c74;font-size:12px;margin-left:6px}
+.copybtn{margin-top:18px;width:100%;background:#5b87e8;border:1px solid #5b87e8;color:#fff;font-weight:600;border-radius:12px;padding:11px;font-size:13px;cursor:pointer}
+.copybtn:hover{background:#4a76d8}
+.hint{color:#a5a29a;font-size:11px;margin-top:8px;line-height:1.7}
+.toast{position:fixed;left:50%;bottom:34px;transform:translate(-50%,14px);background:#33333d;color:#fff;font-size:13px;
+       padding:10px 20px;border-radius:999px;opacity:0;pointer-events:none;transition:all .22s;z-index:20;box-shadow:0 6px 20px rgba(0,0,0,.16)}
+.toast.on{opacity:1;transform:translate(-50%,0)}
+@media(max-width:1100px){.wrap{padding:22px 56px 50px 20px}.kpis{grid-template-columns:repeat(2,1fr)}}
+@media(max-width:960px){.charts{grid-template-columns:1fr}}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <header><div class="brand">怡呵美 · 水光会员店</div><h1>门店数据工作台 · __MONTH__</h1><span class="sub" id="ruleNote">规则来源:《门店考核指标》官方基准线</span><span class="sub">· 共 __COUNT__ 家门店</span></header>
+  <div class="kpis" id="kpis"></div>
+  <div class="charts">
+    <div class="panel"><h3>问题命中分布(按类型)</h3><div class="chart" id="c1"></div></div>
+    <div class="panel"><h3 id="c2title">核销金额分布</h3><div class="chart" id="c2"></div></div>
+    <div class="panel"><h3>门店数 TOP15 城市</h3><div class="chart" id="c3"></div></div>
+  </div>
+  <div class="bar">
+    <input id="q" placeholder="搜索门店名称或ID">
+    <select id="fcity"><option value="">全部城市</option></select>
+    <select id="fcat"><option value="">全部问题类型</option></select>
+    <select id="fsort">
+      <option value="sev">按严重度排序</option>
+      <option value="amt">按核销金额(低→高)</option>
+      <option value="score">按评分(低→高)</option>
+      <option value="hx">按核销率(低→高)</option>
+      <option value="visit">按访问量(高→低)</option>
+    </select>
+    <span class="sub" id="cnt"></span>
+  </div>
+  <div class="panel" style="padding:4px 10px">
+    <table><thead><tr>
+      <th>门店</th><th>城市</th>
+      <th class="num">评分</th><th class="num">核销率</th><th class="num">核销金额</th><th class="num">退单率</th>
+      <th>触发问题</th>
+    </tr></thead><tbody id="tb"></tbody></table>
+    <div class="pager"><span id="pginfo"></span><button id="prev">上一页</button><button id="next">下一页</button></div>
+  </div>
+</div>
+
+<aside class="rail">
+  <button class="railitem" id="btnTh">考核指标<span class="rdot"></span></button>
+  <button class="railitem" id="btnExport">导出清单</button>
+</aside>
+
+<div class="overlay" id="ov"></div>
+<div class="drawer" id="dw"></div>
+
+<aside class="thpanel" id="thPanel">
+  <div class="thhead">
+    <h4>考核指标 <span class="pill">默认值</span><span id="thClose" style="margin-left:auto;cursor:pointer;color:#a5a29a;font-size:18px;line-height:1" title="关闭">&times;</span></h4>
+    <p>当前数值来自《门店考核指标》官方基准线。修改后点【应用到工作台】,会按新阈值重算全部门店的检测结果。</p>
+  </div>
+  <div class="thbody" id="thBody"></div>
+  <div class="thfoot">
+    <button id="thReset">恢复默认</button>
+    <button class="primary" id="thApply">应用到工作台</button>
+  </div>
+  <div class="thnote">自定义值保存在本机浏览器,下次打开仍在;换电脑或换人查看时是默认值。</div>
+</aside>
+<div class="toast" id="toast"></div>
+
+<script>
+const DATA = __DATA__;
+const DEFAULT_T = __THRESH__;
+const MONTH = '__MONTH__';
+const PAGESIZE = 50;
+const LSKEY = 'yhm_thresh_' + MONTH;
+
+let T = JSON.parse(JSON.stringify(DEFAULT_T));
+try{
+  const saved = localStorage.getItem(LSKEY);
+  if(saved){ const s = JSON.parse(saved); Object.keys(T).forEach(k=>{ if(k in s) T[k]=s[k]; }); }
+}catch(e){}
+const isCustom = ()=> JSON.stringify(T)!==JSON.stringify(DEFAULT_T);
+
+const state = {kpi:'ALL', city:'', cat:'', q:'', sort:'sev', page:0};
+
+/* ---------- 检测规则(与 detect.py 保持一致) ---------- */
+const LVW = {R3:3, R2:2, R1:1, B:.6, T:.3};
+const LVNAME = {R1:'预警线', R2:'一般严重线', R3:'非常严重线'};
+function band(v, lines){
+  if(v==null) return null;
+  if(v>lines[2]) return 'R3';
+  if(v>lines[1]) return 'R2';
+  if(v>lines[0]) return 'R1';
+  return null;
+}
+function catOf(l){
+  if(l.indexOf('商责退单率')===0) return '退单率超线';
+  if(l.indexOf('投诉率')===0) return '投诉率超线';
+  if(l.indexOf('差评率')===0) return '差评率超线';
+  if(l.indexOf('评分')===0) return '评分不达标';
+  if(l.indexOf('核销率')===0) return '核销率不达标';
+  if(l.indexOf('本月零核销')===0) return '零核销';
+  if(l.indexOf('核销金额')===0) return '核销金额未达标';
+  if(l.indexOf('评价回复率')===0) return '回复率偏低';
+  if(l.indexOf('月访问')===0) return '访问过低';
+  if(l.indexOf('本月零视频')===0) return '零视频';
+  return '其他';
+}
+
+function detectAll(){
+  DATA.forEach(d=>{
+    const f = [];
+    let lv = band(d.td, T.tuDan);
+    if(lv) f.push([lv, '商责退单率'+(d.td*100).toFixed(2)+'%超'+LVNAME[lv], '催办到店核销、主动处理退单商责, 避免平台处置']);
+    lv = band(d.ts, T.touSu);
+    if(lv) f.push([lv, '投诉率'+(d.ts*100).toFixed(2)+'%超线', '24小时内响应投诉工单, 复盘服务环节']);
+    lv = band(d.cp, T.chaPing);
+    if(lv) f.push([lv, '差评率('+d.cp_src+'口径)'+(d.cp*100).toFixed(2)+'%超线', '处理近30天差评, 主攻优质深度好评']);
+    if(d.score!=null && d.score < T.score_base)
+      f.push(['B', '评分'+d.score.toFixed(1)+'低于考核基准'+T.score_base, '引导好评+回复差评, 目标先回'+T.score_base+'再冲'+T.score_target]);
+    if(d.hx_rate!=null && d.hx_rate < T.hx_rate_base)
+      f.push(['B', '核销率'+(d.hx_rate*100).toFixed(0)+'%低于基准'+(T.hx_rate_base*100).toFixed(0)+'%', '电话/私信触达待核销客户, 加速到店']);
+    if(d.hx_amt < T.hx_amt_base){
+      if(d.hx_cnt===0) f.push(['B', '本月零核销', '检查团购在售状态与价格, 重新起量']);
+      else f.push(['B', '核销金额'+Math.round(d.hx_amt)+'元低于基准'+T.hx_amt_base, '补齐在售品结构, 冲单量']);
+    }
+    if(d.reply!=null && d.reply < T.reply_base/100)
+      f.push(['T', '评价回复率'+Math.round(d.reply*100)+'%偏低', '安排专人回复全部评价']);
+    if(d.visit < T.visit_low)
+      f.push(['T', '月访问仅'+d.visit+'次', '检查POI完整度, 点亮门店+更新AIGC素材']);
+    if(d.videos===0)
+      f.push(['T', '本月零视频', '后台AIGC工具定期产出图片/视频']);
+    d.flags = f;
+    d.sev = f.reduce((m,x)=>Math.max(m, LVW[x[0]]), 0);
+    d.cats = f.map(x=>catOf(x[1]));
+  });
+}
+const CITIES = [...new Set(DATA.map(d=>d.city))].sort();
+
+/* ---------- KPI ---------- */
+const KPI = [
+ {k:'ALL', l:()=>'全部门店', hot:0, f:()=>true},
+ {k:'R', l:()=>'超违规预警线(退单/投诉/差评)', hot:1, f:d=>d.flags.some(x=>x[0][0]==='R')},
+ {k:'SCORE', l:()=>'评分低于基准'+T.score_base, hot:1, f:d=>d.score!=null&&d.score<T.score_base},
+ {k:'HX', l:()=>'核销率低于基准'+(T.hx_rate_base*100).toFixed(0)+'%', hot:1, f:d=>d.hx_rate!=null&&d.hx_rate<T.hx_rate_base},
+ {k:'ZERO', l:()=>'本月零核销', hot:1, f:d=>d.hx_cnt===0},
+];
+
+function filtered(){
+  const kf = KPI.find(k=>k.k===state.kpi).f;
+  let rows = DATA.filter(kf);
+  if(state.city) rows = rows.filter(d=>d.city===state.city);
+  if(state.cat) rows = rows.filter(d=>d.cats.indexOf(state.cat)>=0);
+  if(state.q){ const q=state.q.toLowerCase();
+    rows = rows.filter(d=>d.name.toLowerCase().indexOf(q)>=0 || d.id.indexOf(q)>=0); }
+  const nullLast = (v)=> v==null;
+  const s = state.sort;
+  rows.sort((a,b)=>{
+    if(s==='amt') return (a.hx_amt||0)-(b.hx_amt||0);
+    if(s==='visit') return (b.visit||0)-(a.visit||0);
+    if(s==='score'){ if(nullLast(a.score)&&nullLast(b.score))return 0; if(nullLast(a.score))return 1; if(nullLast(b.score))return -1; return a.score-b.score; }
+    if(s==='hx'){ if(nullLast(a.hx_rate)&&nullLast(b.hx_rate))return 0; if(nullLast(a.hx_rate))return 1; if(nullLast(b.hx_rate))return -1; return a.hx_rate-b.hx_rate; }
+    return b.sev-a.sev || (a.hx_amt||0)-(b.hx_amt||0);
+  });
+  return rows;
+}
+
+function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;'); }
+function pct(v,d){ return v==null?'-':(v*100).toFixed(d||0)+'%'; }
+
+function renderKpis(){
+  document.getElementById('kpis').innerHTML = KPI.map(k=>{
+    const n = DATA.filter(k.f).length;
+    return '<div class="kpi'+(k.hot?' hot':'')+(state.kpi===k.k?' on':'')+'" data-k="'+k.k+'"><div class="v">'+n+' 家</div><div class="l">'+k.l()+'</div></div>';
+  }).join('');
+  document.querySelectorAll('.kpi').forEach(el=>el.onclick=()=>{
+    state.kpi = el.dataset.k; state.page=0; renderKpis(); renderTable();
+  });
+}
+
+/* ---------- 图表 ---------- */
+let ch1=null, ch2=null, ch3=null;
+function renderCharts(){
+  if(!window.echarts) return;
+  const ax = {axisLine:{lineStyle:{color:'#e0ddd4'}}, axisLabel:{color:'#8f8d85',fontSize:11}, splitLine:{lineStyle:{color:'#f1efe8'}}};
+  if(!ch1) ch1 = echarts.init(document.getElementById('c1'));
+  const cnt = {};
+  DATA.forEach(d=>d.cats.forEach(c=>cnt[c]=(cnt[c]||0)+1));
+  const e1 = Object.entries(cnt).sort((a,b)=>a[1]-b[1]);
+  ch1.setOption({grid:{left:110,top:8,bottom:24,right:30},
+    xAxis:{type:'value',...ax}, yAxis:{type:'category',data:e1.map(e=>e[0]),axisLabel:{color:'#6b695f',fontSize:11}},
+    tooltip:{}, series:[{type:'bar',data:e1.map(e=>e[1]),barWidth:12,itemStyle:{color:'#7fa5ec',borderRadius:[0,4,4,0]}}]}, true);
+
+  let b = [0,0,0,0];
+  DATA.forEach(d=>{ const a=d.hx_amt||0;
+    if(a===0) b[0]++; else if(a<1000) b[1]++; else if(a<T.hx_amt_base) b[2]++; else b[3]++; });
+  const baseLabel = T.hx_amt_base>=10000 ? '达标≥'+(T.hx_amt_base/10000).toFixed(1)+'万' : '达标≥'+T.hx_amt_base;
+  document.getElementById('c2title').textContent = '核销金额分布(基准 '+T.hx_amt_base+' 元)';
+  if(!ch2) ch2 = echarts.init(document.getElementById('c2'));
+  ch2.setOption({grid:{left:44,top:14,bottom:40,right:16},
+    xAxis:{type:'category',data:['零核销','0-1千','1千-基准',baseLabel],...ax},
+    yAxis:{type:'value',...ax},
+    tooltip:{}, series:[{type:'bar',data:[
+      {value:b[0],itemStyle:{color:'#e9695f'}},{value:b[1],itemStyle:{color:'#f2a65a'}},
+      {value:b[2],itemStyle:{color:'#f5d472'}},{value:b[3],itemStyle:{color:'#6fc49a'}}],
+      barWidth:26,itemStyle:{borderRadius:[4,4,0,0]},label:{show:true,position:'top',color:'#8f8d85',fontSize:11}}]}, true);
+
+  const cc = {};
+  DATA.forEach(d=>cc[d.city]=(cc[d.city]||0)+1);
+  const e3 = Object.entries(cc).sort((a,b)=>b[1]-a[1]).slice(0,15).reverse();
+  if(!ch3) ch3 = echarts.init(document.getElementById('c3'));
+  ch3.setOption({grid:{left:64,top:8,bottom:24,right:30},
+    xAxis:{type:'value',...ax}, yAxis:{type:'category',data:e3.map(e=>e[0]),axisLabel:{color:'#6b695f',fontSize:11}},
+    tooltip:{}, series:[{type:'bar',data:e3.map(e=>e[1]),barWidth:11,itemStyle:{color:'#9fc0f0',borderRadius:[0,4,4,0]}}]}, true);
+}
+
+/* ---------- 表格 ---------- */
+function renderTable(){
+  const rows = filtered();
+  const pages = Math.max(1, Math.ceil(rows.length/PAGESIZE));
+  if(state.page>=pages) state.page = pages-1;
+  const slice = rows.slice(state.page*PAGESIZE, state.page*PAGESIZE+PAGESIZE);
+  const sevColor = s=> s>=3?'#e05a48': s>=2?'#f08a4b': s>=1?'#f0b45c': s>=.6?'#7fa5ec':'#c9c6bd';
+  document.getElementById('tb').innerHTML = slice.map(d=>
+    '<tr class="row" data-id="'+d.id+'"><td><div class="nm"><span class="dot" style="background:'+sevColor(d.sev)+'"></span>'+esc(d.name)+'</div><div class="sid">'+d.id+'</div></td>'+
+    '<td>'+esc(d.city)+'</td>'+
+    '<td class="num">'+(d.score==null?'-':d.score.toFixed(1))+'</td>'+
+    '<td class="num">'+pct(d.hx_rate)+'</td>'+
+    '<td class="num">'+Math.round(d.hx_amt).toLocaleString()+'</td>'+
+    '<td class="num">'+pct(d.td,2)+'</td>'+
+    '<td style="max-width:330px">'+d.flags.slice(0,4).map(f=>'<span class="tag t-'+f[0]+'">'+esc(f[1])+'</span>').join('')+(d.flags.length>4?'<span class="tag t-T">+'+(d.flags.length-4)+'</span>':'')+'</td></tr>'
+  ).join('') || '<tr><td colspan="7" style="color:#a5a29a;padding:30px;text-align:center">没有符合筛选条件的门店</td></tr>';
+  document.querySelectorAll('tr.row').forEach(el=>el.onclick=()=>openDrawer(el.dataset.id));
+  document.getElementById('cnt').textContent = '共 '+rows.length+' 家';
+  document.getElementById('pginfo').textContent = '第 '+(state.page+1)+' / '+pages+' 页';
+  document.getElementById('prev').disabled = state.page===0;
+  document.getElementById('next').disabled = state.page>=pages-1;
+}
+
+/* ---------- 门店详情 ---------- */
+function buildMetrics(){
+  return [
+   {k:'评分', v:d=>d.score, fmt:v=>v.toFixed(1), max:5, base:T.score_base, target:T.score_target, dir:'high'},
+   {k:'核销率', v:d=>d.hx_rate, fmt:v=>pct(v), max:1, base:T.hx_rate_base, target:T.hx_rate_target, dir:'high'},
+   {k:'核销金额', v:d=>d.hx_amt, fmt:v=>Math.round(v).toLocaleString()+'元', max:T.hx_amt_base*2, base:T.hx_amt_base, target:T.hx_amt_base, dir:'high'},
+   {k:'商责退单率', v:d=>d.td, fmt:v=>pct(v,2), max:T.tuDan[2]*1.1, warn:T.tuDan, dir:'low'},
+   {k:'投诉率', v:d=>d.ts, fmt:v=>pct(v,2), max:T.touSu[2]*1.4, warn:T.touSu, dir:'low'},
+   {k:'差评率', v:d=>d.cp, fmt:v=>v==null?'样本不足':pct(v,2), max:T.chaPing[2]*1.4, warn:T.chaPing, dir:'low'},
+   {k:'评价回复率', v:d=>d.reply, fmt:v=>pct(v), max:1, base:T.reply_base/100, target:1, dir:'high'},
+  ];
+}
+function barColor(m, v){
+  if(v==null) return '#d5d2c8';
+  if(m.dir==='high') return v<m.base?'#e9695f': v<m.target?'#f2a65a':'#6fc49a';
+  return v>m.warn[2]?'#e05a48': v>m.warn[1]?'#f08a4b': v>m.warn[0]?'#f2a65a':'#6fc49a';
+}
+
+function openDrawer(id){
+  const d = DATA.find(x=>x.id===id);
+  if(!d) return;
+  closePanel();
+  let h = '<h2>'+esc(d.name)+'</h2><div class="sid">'+d.id+' · '+esc(d.city)+' · 严重度 '+d.sev.toFixed(1)+'</div>';
+  h += '<div>' + d.flags.map(f=>'<span class="tag t-'+f[0]+'">'+esc(f[1])+'</span>').join('') + (d.flags.length?'':'<span class="tag t-T">本月无明显异常</span>') + '</div>';
+  h += buildMetrics().map(m=>{
+    const v = m.v(d);
+    const w = v==null?0:Math.min(v/m.max*100,100);
+    let marks = '';
+    if(m.warn) marks = m.warn.map(x=>'<span class="mk" style="left:'+(x/m.max*100).toFixed(1)+'%"></span>').join('');
+    else marks = '<span class="mk" style="left:'+(m.base/m.max*100).toFixed(1)+'%"></span><span class="mk" style="left:'+(m.target/m.max*100).toFixed(1)+'%;background:#6fc49a"></span>';
+    return '<div class="mrow"><div class="lab"><span>'+m.k+'</span><span>'+(v==null?'-':m.fmt(v))+'</span></div>'+
+      '<div class="track"><div class="fill" style="width:'+w.toFixed(1)+'%;background:'+barColor(m,v)+'"></div>'+marks+'</div></div>';
+  }).join('');
+  h += '<div class="mrow" style="color:#8f8d85;font-size:12px">月访问 '+d.visit.toLocaleString()+' 次 · 本月新发视频 '+d.videos+' 个 · 商责退单率 '+pct(d.td,2)+'</div>';
+  if(d.flags.length){
+    h += '<div style="margin-top:14px;font-size:13px">' + d.flags.map(f=>'<div class="flag"><span class="tag t-'+f[0]+'">'+esc(f[1])+'</span><span class="act">'+esc(f[2])+'</span></div>').join('') + '</div>';
+  }
+  h += '<button class="copybtn" id="cp">一键复制该店反馈文案(反馈格式)</button><div class="hint">复制后可直接粘贴给门店/督导,格式与《门店评估后反馈格式》一致。灰色竖线=基准线,绿色竖线=目标线。</div>';
+  const dw = document.getElementById('dw');
+  dw.innerHTML = h;
+  dw.classList.add('on'); document.getElementById('ov').classList.add('on');
+  document.getElementById('cp').onclick = ()=>copyFeedback(d);
+}
+
+function feedbackText(d){
+  const L = [];
+  L.push('✅经营数据概览:');
+  L.push(MONTH+':门店页访问'+d.visit.toLocaleString()+' 核销'+Math.round(d.hx_amt).toLocaleString()+'元 评分'+(d.score==null?'-':d.score.toFixed(1)));
+  L.push('===');
+  L.push('✅风险或限流因素:');
+  d.flags.filter(f=>f[0][0]==='R'||f[0]==='B').forEach((f,i)=>L.push((i+1)+'、'+f[1]));
+  L.push('===');
+  L.push('✅整改建议:');
+  const seen = new Set();
+  d.flags.forEach(f=>{ if(!seen.has(f[2])){ seen.add(f[2]); L.push('· '+f[2]); } });
+  return L.join('\n');
+}
+function copyFeedback(d){
+  const t = feedbackText(d);
+  if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(t); }
+  else{ const ta=document.createElement('textarea'); ta.value=t; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
+  const b=document.getElementById('cp'); b.textContent='已复制 ✓'; setTimeout(()=>b.textContent='一键复制该店反馈文案(反馈格式)',1600);
+}
+
+/* ---------- 考核指标面板 ---------- */
+const SPEC = [
+ {g:'门店评分', hint:'低于基准即判「评分不达标」', items:[
+   {p:'score_base', l:'考核基准', step:'0.1'},
+   {p:'score_target', l:'目标值', step:'0.1'}]},
+ {g:'核销率', hint:'核销券数 ÷ 意向成交券数', pct:1, items:[
+   {p:'hx_rate_base', l:'考核基准', step:'1'},
+   {p:'hx_rate_target', l:'目标值', step:'1'}]},
+ {g:'核销金额', hint:'单店月度核销金额,低于基准即提醒', items:[
+   {p:'hx_amt_base', l:'考核基准(元)', step:'500'}]},
+ {g:'商责退单率', hint:'超出即计入违规,分三级预警', pct:1, items:[
+   {p:'tuDan.0', l:'预警线'}, {p:'tuDan.1', l:'一般严重'}, {p:'tuDan.2', l:'非常严重'}]},
+ {g:'投诉率', hint:'超出即计入违规,分三级预警', pct:1, items:[
+   {p:'touSu.0', l:'预警线'}, {p:'touSu.1', l:'一般严重'}, {p:'touSu.2', l:'非常严重'}]},
+ {g:'差评率', hint:'样本不足时不判定(新增<10 且累计<20)', pct:1, items:[
+   {p:'chaPing.0', l:'预警线'}, {p:'chaPing.1', l:'一般严重'}, {p:'chaPing.2', l:'非常严重'}]},
+ {g:'评价回复率', hint:'低于此比例判「回复率偏低」', unit:'%', items:[
+   {p:'reply_base', l:'最低要求'}]},
+ {g:'月访问量', hint:'低于此值判「访问过低」', items:[
+   {p:'visit_low', l:'过低线(次)', step:'10'}]},
+];
+function getP(obj, path){
+  const ks = path.split('.');
+  let v = obj;
+  for(const k of ks){ if(v==null) return null; v = v[k]; }
+  return v;
+}
+function setP(obj, path, val){
+  const ks = path.split('.');
+  let v = obj;
+  for(let i=0;i<ks.length-1;i++){ v = v[ks[i]]; }
+  v[ks[ks.length-1]] = val;
+}
+function uiVal(path, isPct){
+  const raw = getP(T, path);
+  return isPct ? +(raw*100).toFixed(3) : raw;
+}
+function uiValFrom(t, path, isPct){
+  const raw = getP(t, path);
+  return isPct ? +(raw*100).toFixed(3) : raw;
+}
+function unitOf(g){
+  if(g.unit) return g.unit;
+  if(g.pct) return '%';
+  if(g.g.indexOf('金额')>=0) return '元';
+  if(g.g.indexOf('访问')>=0) return '次';
+  return '分';
+}
+function buildPanel(){
+  document.getElementById('thBody').innerHTML = SPEC.map(g=>{
+    const cls = g.items.length===3 ? '' : (g.items.length===2 ? ' one' : ' single');
+    return '<div class="thgroup"><div class="gt">'+g.g+'</div><div class="gh">'+g.hint+'</div>'+
+      '<div class="throw'+cls+'">'+g.items.map(it=>
+        '<div class="thfield"><label>'+it.l+'</label><div class="inp">'+
+        '<input type="number" step="'+(it.step||'0.01')+'" data-p="'+it.p+'" data-pct="'+(g.pct?1:0)+'" value="'+uiVal(it.p, g.pct?1:0)+'">'+
+        '<span class="u">'+unitOf(g)+'</span></div></div>'
+      ).join('')+'</div></div>';
+  }).join('');
+  document.querySelectorAll('#thBody input').forEach(inp=>{
+    inp.oninput = ()=>{
+      const def = uiValFrom(DEFAULT_T, inp.dataset.p, inp.dataset.pct==='1');
+      const cur = parseFloat(inp.value);
+      inp.closest('.thfield').classList.toggle('chg', !isNaN(cur) && Math.abs(cur-def)>1e-9);
+    };
+  });
+}
+
+function readPanel(){
+  const next = JSON.parse(JSON.stringify(T));
+  const errs = [];
+  document.querySelectorAll('#thBody input').forEach(inp=>{
+    const v = parseFloat(inp.value);
+    if(isNaN(v)){ errs.push('有输入框为空'); return; }
+    setP(next, inp.dataset.p, inp.dataset.pct==='1' ? v/100 : v);
+  });
+  [['tuDan','商责退单率'],['touSu','投诉率'],['chaPing','差评率']].forEach(pair=>{
+    const a = next[pair[0]];
+    if(!(a[0]>0 && a[1]>a[0] && a[2]>a[1])) errs.push(pair[1]+' 三级阈值需递增(预警 < 一般 < 非常严重)');
+  });
+  if(next.score_base>=next.score_target) errs.push('评分目标值应高于考核基准');
+  if(next.hx_rate_base>=next.hx_rate_target) errs.push('核销率目标值应高于考核基准');
+  return {next:next, errs:[...new Set(errs)]};
+}
+
+function toast(msg){
+  const t = document.getElementById('toast');
+  t.textContent = msg; t.classList.add('on');
+  clearTimeout(t._tm); t._tm = setTimeout(()=>t.classList.remove('on'), 2400);
+}
+
+function fillSelects(){
+  const csel = document.getElementById('fcity');
+  csel.innerHTML = '<option value="">全部城市</option>';
+  CITIES.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; csel.appendChild(o); });
+  const cats = [...new Set(DATA.flatMap(d=>d.cats))].sort();
+  const ksel = document.getElementById('fcat');
+  ksel.innerHTML = '<option value="">全部问题类型</option>';
+  cats.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; ksel.appendChild(o); });
+  if(state.city && CITIES.indexOf(state.city)<0){ state.city=''; csel.value=''; }
+  if(state.cat && cats.indexOf(state.cat)<0){ state.cat=''; ksel.value=''; }
+  csel.value = state.city; ksel.value = state.cat;
+}
+
+function refreshAll(){
+  detectAll();
+  fillSelects();
+  renderKpis();
+  renderCharts();
+  renderTable();
+  document.getElementById('ruleNote').textContent = isCustom()
+    ? '规则:自定义考核指标(默认值来自《门店考核指标》)'
+    : '规则来源:《门店考核指标》官方基准线';
+  document.getElementById('btnTh').classList.toggle('mod', isCustom());
+}
+
+const OVEL = document.getElementById('ov');
+function openPanel(){
+  buildPanel();
+  closeDrawer();
+  document.getElementById('thPanel').classList.add('on');
+  document.getElementById('btnTh').classList.add('on');
+  OVEL.classList.add('on','hi');
+}
+function closePanel(){
+  document.getElementById('thPanel').classList.remove('on');
+  document.getElementById('btnTh').classList.remove('on');
+  OVEL.classList.remove('hi');
+}
+function closeDrawer(){
+  document.getElementById('dw').classList.remove('on');
+  document.getElementById('ov').classList.remove('on');
+}
+
+function exportCsv(){
+  const rows = filtered();
+  const head = ['门店ID','门店名称','城市','评分','核销率','核销金额','商责退单率','投诉率','差评率','评价回复率','月访问','本月新视频','触发问题','建议动作'];
+  const lines = [head.join(',')];
+  rows.forEach(d=>{
+    const acts = [...new Set(d.flags.map(f=>f[2]))];
+    const cells = [d.id, d.name, d.city,
+      d.score==null?'':d.score.toFixed(1),
+      d.hx_rate==null?'':(d.hx_rate*100).toFixed(1)+'%',
+      Math.round(d.hx_amt),
+      (d.td*100).toFixed(2)+'%', (d.ts*100).toFixed(2)+'%',
+      d.cp==null?'':(d.cp*100).toFixed(2)+'%',
+      d.reply==null?'':Math.round(d.reply*100)+'%',
+      d.visit, d.videos,
+      d.flags.map(f=>f[1]).join(' | '), acts.join(' ;')];
+    lines.push(cells.map(c=>'"'+String(c).replace(/"/g,'""')+'"').join(','));
+  });
+  const blob = new Blob(['\ufeff'+lines.join('\r\n')], {type:'text/csv;charset=utf-8'});
+  const a = document.createElement('a');
+  a.href = URL.createObjectURL(blob);
+  a.download = '门店检测清单_'+MONTH+(isCustom()?'_自定义阈值':'')+'.csv';
+  document.body.appendChild(a); a.click(); a.remove();
+  toast('已导出 '+rows.length+' 家门店清单');
+}
+
+/* ---------- 事件绑定 ---------- */
+OVEL.onclick = ()=>{ closeDrawer(); closePanel(); };
+document.getElementById('thClose').onclick = closePanel;
+document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeDrawer(); closePanel(); } });
+document.getElementById('q').oninput = e=>{ state.q=e.target.value.trim(); state.page=0; renderTable(); };
+document.getElementById('fcity').onchange = e=>{ state.city=e.target.value; state.page=0; renderTable(); };
+document.getElementById('fcat').onchange = e=>{ state.cat=e.target.value; state.page=0; renderTable(); };
+document.getElementById('fsort').onchange = e=>{ state.sort=e.target.value; state.page=0; renderTable(); };
+document.getElementById('prev').onclick = ()=>{ state.page--; renderTable(); };
+document.getElementById('next').onclick = ()=>{ state.page++; renderTable(); };
+document.getElementById('btnTh').onclick = ()=>{ document.getElementById('thPanel').classList.contains('on') ? closePanel() : openPanel(); };
+document.getElementById('btnExport').onclick = exportCsv;
+
+document.getElementById('thApply').onclick = ()=>{
+  const r = readPanel();
+  if(r.errs.length){ toast('请检查:'+r.errs[0]); return; }
+  T = r.next;
+  try{ localStorage.setItem(LSKEY, JSON.stringify(T)); }catch(e){}
+  closePanel();
+  refreshAll();
+  toast('已按新阈值重算 '+DATA.length+' 家门店');
+};
+document.getElementById('thReset').onclick = ()=>{
+  T = JSON.parse(JSON.stringify(DEFAULT_T));
+  try{ localStorage.removeItem(LSKEY); }catch(e){}
+  buildPanel();
+  refreshAll();
+  toast('已恢复官方默认基准线');
+};
+
+/* ---------- 初始化 ---------- */
+detectAll();
+fillSelects();
+renderKpis(); renderCharts(); renderTable();
+document.getElementById('ruleNote').textContent = isCustom()
+  ? '规则:自定义考核指标(默认值来自《门店考核指标》)'
+  : '规则来源:《门店考核指标》官方基准线';
+document.getElementById('btnTh').classList.toggle('mod', isCustom());
+window.addEventListener('resize', ()=>{ if(ch1)ch1.resize(); if(ch2)ch2.resize(); if(ch3)ch3.resize(); });
+if(location.hash==='#TH'){ openPanel(); }
+else if(location.hash.length>2){ openDrawer(location.hash.slice(1)); }
+</script>
+</body>
+</html>
+"""
+
+
+def main():
+    args = sys.argv[1:]
+    src = args[0] if args and args[0].endswith('.csv') else os.path.join(BASE, '门店数据_10月.csv')
+    month = '2026-10'
+    if '--month' in args:
+        month = args[args.index('--month') + 1]
+    rows = list(csv.DictReader(open(src, encoding='utf-8-sig')))
+    metrics, conflicts = detect(rows)
+    # 只传原始字段,检测逻辑放到前端,阈值改动后可直接重算
+    data = [dict(id=m['门店ID'], name=m['门店名称'], city=m['城市'],
+                 score=m['评分'], hx_rate=m['核销率'], hx_amt=m['核销金额'],
+                 hx_cnt=m['核销券数'], td=m['商责退单率'], ts=m['投诉率'],
+                 cp=m['差评率'], cp_src=m['差评口径'], reply=m['回复率'],
+                 visit=int(m['月访问']), videos=int(m['新视频']))
+            for m in metrics]
+    html = (TEMPLATE
+            .replace('__DATA__', json.dumps(data, ensure_ascii=False, separators=(',', ':')))
+            .replace('__THRESH__', json.dumps(THRESH))
+            .replace('__MONTH__', month)
+            .replace('__COUNT__', str(len(data))))
+    out = os.path.join(OUT, f'门店工作台_{month.replace("-", "")}.html')
+    with open(out, 'w', encoding='utf-8') as fp:
+        fp.write(html)
+    print(f'工作台已生成: {out}  ({os.path.getsize(out) // 1024} KB, {len(data)} 家门店)')
+
+
+if __name__ == '__main__':
+    main()

+ 310 - 0
对标试点/03_分析/build_report.py

@@ -0,0 +1,310 @@
+# -*- coding: utf-8 -*-
+"""
+同城对标检测 - 分析报告生成器
+输入: 采集快照 CSV (每次采集累加, 一个商品一行)
+输出: 04_输出/对标检测报告_<日期>.html
+
+用法:
+    python build_report.py                      # 用最新快照
+    python build_report.py --demo               # 用内置模拟数据跑演示
+    python build_report.py --file xxx.csv       # 指定快照文件
+"""
+import csv
+import os
+import sys
+import random
+import statistics
+from datetime import date
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+OUT_DIR = os.path.join(os.path.dirname(BASE), '04_输出')
+SNAP_DIR = os.path.join(os.path.dirname(BASE), '03_分析')
+
+SNAP_FIELDS = ['采集日期', '门店编码', '门店名', '门店类型', 'POI链接',
+               '商品名', '原价', '团购价', '已售', '评分', '评价数']
+
+PRODUCTS = ['面部深层清洁', '超微小气泡', '光子嫩肤单次', '水光补水',
+            '肩颈舒缓60min', '头皮深层清洁', '果酸焕肤', '淡斑管理',
+            '背部疏通', '眼部护理', '小气泡3次卡', '面部抗衰套餐']
+
+
+def med(xs):
+    xs = [x for x in xs if x is not None]
+    return statistics.median(xs) if xs else 0.0
+
+
+def pct(x):
+    return f'{x * 100:.0f}%'
+
+
+# ---------------------------------------------------------------- demo data
+def build_demo():
+    """构造 10 家本店 x 4~5 家竞对 的模拟快照, 用于验证报告长什么样"""
+    rnd = random.Random(20261029)
+    today = date.today().isoformat()
+    rows = []
+    profiles = [
+        ('C001', 168, 1.28, 4.62, 0.55),
+        ('C002', 198, 0.92, 4.81, 1.35),
+        ('C003', 128, 1.05, 4.75, 1.10),
+        ('C004', 158, 1.42, 4.51, 0.42),
+        ('C005', 178, 0.88, 4.86, 1.62),
+        ('C006', 148, 1.12, 4.68, 0.86),
+        ('C007', 188, 1.35, 4.58, 0.51),
+        ('C008', 138, 0.98, 4.79, 1.24),
+        ('C009', 168, 1.08, 4.70, 0.95),
+        ('C010', 208, 1.25, 4.55, 0.60),
+    ]
+    for code, base_price, price_k, rating, strength in profiles:
+        n_comp = rnd.choice([4, 4, 5])
+        rows.append(dict(采集日期=today, 门店编码=code, 门店名=f'{code}门店',
+                         门店类型='本店', POI链接=f'https://v.douyin.com/{code}/',
+                         商品名='', 原价=0, 团购价=0, 已售=0,
+                         评分=rating, 评价数=rnd.randint(300, 2400)))
+        own_n = min(rnd.randint(6, 11), len(PRODUCTS))
+        own_sold_total = int(rnd.randint(600, 3000) * strength)
+        for nm in rnd.sample(PRODUCTS, own_n):
+            price = round(base_price * rnd.uniform(0.6, 1.6))
+            rows.append(dict(采集日期=today, 门店编码=code, 门店名=f'{code}门店',
+                             门店类型='本店', POI链接=f'https://v.douyin.com/{code}/',
+                             商品名=nm, 原价=round(price * rnd.uniform(1.6, 2.8)),
+                             团购价=price, 已售=max(3, own_sold_total // own_n + rnd.randint(-40, 40)),
+                             评分=rating, 评价数=0))
+        for i in range(n_comp):
+            cname = f'{code}商圈·竞对{i + 1}'
+            c_rating = round(rating + rnd.uniform(-0.35, 0.35), 1)
+            c_n = min(rnd.randint(4, 14), len(PRODUCTS))
+            comp_pool = int(rnd.randint(500, 3200) / max(strength, 0.3))
+            for nm in rnd.sample(PRODUCTS, c_n):
+                price = round(base_price * rnd.uniform(0.7, 1.5) / price_k)
+                rows.append(dict(采集日期=today, 门店编码=code, 门店名=cname,
+                                 门店类型='竞对', POI链接=f'https://v.douyin.com/x{code}{i}/',
+                                 商品名=nm, 原价=round(price * rnd.uniform(1.5, 2.6)),
+                                 团购价=price, 已售=max(2, comp_pool // c_n + rnd.randint(-30, 30)),
+                                 评分=c_rating, 评价数=rnd.randint(100, 2000)))
+    return rows
+
+
+# ---------------------------------------------------------------- analysis
+def analyse(rows):
+    groups = {}
+    for r in rows:
+        groups.setdefault(r['门店编码'], []).append(r)
+
+    result = []
+    for code, rs in groups.items():
+        own = [r for r in rs if r['门店类型'] == '本店' and r['商品名']]
+        comp = [r for r in rs if r['门店类型'] == '竞对' and r['商品名']]
+        if not own:
+            continue
+        comp_names = {r['门店名'] for r in comp}
+        n = len(comp_names) or 1
+
+        own_price = med([r['团购价'] for r in own])
+        comp_price = med([r['团购价'] for r in comp])
+        own_cnt = len({r['商品名'] for r in own})
+        cnt_by_store = {}
+        for r in comp:
+            cnt_by_store.setdefault(r['门店名'], set()).add(r['商品名'])
+        comp_cnt = med([len(v) for v in cnt_by_store.values()]) if cnt_by_store else own_cnt
+
+        own_sold = sum(r['已售'] for r in own)
+        comp_sold = sum(r['已售'] for r in comp)
+        total = own_sold + comp_sold or 1
+        share = own_sold / total
+        share_idx = share * (n + 1)
+
+        price_idx = own_price / comp_price if comp_price else 1.0
+        own_rating = next((r['评分'] for r in own), 0)
+        comp_rating = med([r['评分'] for r in rs if r['门店类型'] == '竞对'])
+        gap = own_rating - (comp_rating or own_rating)
+
+        composite = (0.5 * share_idx
+                     + 0.3 * (1 + gap / 0.5 * 0.2)
+                     + 0.2 * (1 + (1 - price_idx) * 0.5))
+
+        result.append(dict(
+            编码=code, 门店名=f'{code}门店', 竞对数=n,
+            本店商品数=own_cnt, 竞对商品数=comp_cnt,
+            本店价=own_price, 同城价=comp_price, 价格指数=price_idx,
+            本店销量=own_sold, 同城销量=comp_sold, 份额=share, 份额指数=share_idx,
+            本店评分=own_rating, 同城评分=comp_rating, 评分差=gap,
+            竞争力=composite,
+        ))
+    result.sort(key=lambda x: x['份额指数'])
+    return result
+
+
+def judge(m):
+    """检测规则 -> (标签, 等级, 归因建议)"""
+    rules = []
+    if m['价格指数'] > 1.2 and m['份额指数'] < 0.8:
+        rules.append(('定价偏高', 'danger',
+                      f"价格高于同城中位 {pct(m['价格指数'] - 1)},销量份额仅为平均水平的 {m['份额指数']:.2f} 倍。"
+                      f"优先检查主图与团购标题,其次考虑下调入门款价格。"))
+    if m['价格指数'] < 0.85 and m['份额指数'] < 0.8:
+        rules.append(('性价比未被感知', 'warn',
+                      f"价格低于同城 {pct(1 - m['价格指数'])},但份额只有平均的 {m['份额指数']:.2f} 倍。"
+                      f"不是价格问题,是曝光或评价数量不够,检查达人铺设与评价增速。"))
+    if m['竞对商品数'] and m['本店商品数'] < m['竞对商品数'] * 0.6:
+        rules.append(('品项不足', 'warn',
+                      f"在售 {m['本店商品数']} 个,同城中位 {m['竞对商品数']:.0f} 个。"
+                      f"补齐引流款+利润款的组合,至少到中位水平。"))
+    if m['评分差'] < -0.15:
+        rules.append(('口碑落后', 'danger',
+                      f"评分低于同城中位 {abs(m['评分差']):.2f} 分。差评未处理会直接压转化,先处理近 30 天差评。"))
+    if m['份额指数'] > 1.3 and m['价格指数'] < 1.1:
+        rules.append(('优势店', 'good', '价格不高于同城且份额领先,把这家店的商品结构和内容打法复制到同区域其他店。'))
+    if not rules:
+        rules.append(('与商圈持平', 'flat', '各项指标接近同城水平,暂无明显短板。'))
+    return rules
+
+
+# ---------------------------------------------------------------- render
+CSS = """
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:#0f1115;color:#e6e8ec;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
+     font-size:14px;line-height:1.65;padding:36px 40px 64px}
+.wrap{max-width:1080px;margin:0 auto}
+h1{font-size:22px;font-weight:600;letter-spacing:.2px}
+.sub{color:#8b93a1;font-size:13px;margin-top:8px}
+.kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:26px 0 30px}
+.kpi{background:#171a21;border:1px solid #252a34;border-radius:12px;padding:16px 18px}
+.kpi .v{font-size:24px;font-weight:600;font-variant-numeric:tabular-nums}
+.kpi .l{color:#8b93a1;font-size:12px;margin-top:2px}
+h2{font-size:15px;font-weight:600;margin:34px 0 14px;display:flex;align-items:center;gap:8px}
+h2 .bar{width:3px;height:14px;background:#6aa9ff;border-radius:2px}
+table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}
+th{text-align:right;color:#8b93a1;font-weight:500;font-size:12px;padding:9px 10px;
+   border-bottom:1px solid #2a2f3a;white-space:nowrap}
+th:first-child,th:nth-child(2){text-align:left}
+td{padding:10px;border-bottom:1px solid #1d212a;text-align:right;white-space:nowrap}
+td:first-child,td:nth-child(2){text-align:left}
+tr:hover td{background:#151920}
+.tag{display:inline-block;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:500}
+.t-danger{background:rgba(255,107,107,.14);color:#ff8585}
+.t-warn{background:rgba(240,168,60,.14);color:#f0b45c}
+.t-good{background:rgba(78,201,160,.14);color:#5fd6ae}
+.t-flat{background:rgba(140,148,163,.14);color:#9aa3b2}
+.cards{display:grid;grid-template-columns:1fr;gap:10px}
+.card{background:#171a21;border:1px solid #252a34;border-left:3px solid #2f3745;
+      border-radius:10px;padding:14px 18px}
+.card.danger{border-left-color:#ff6b6b}
+.card.warn{border-left-color:#f0a83c}
+.card.good{border-left-color:#4ec9a0}
+.card .h{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.card .nm{font-weight:600;font-size:14px}
+.card .msg{color:#a8b0be;font-size:13px;margin-top:6px}
+.card .nums{color:#6f7787;font-size:12px;margin-top:6px;font-variant-numeric:tabular-nums}
+.note{color:#767e8c;font-size:12px;line-height:1.9;margin-top:14px}
+.note b{color:#9aa3b2;font-weight:500}
+.up{color:#ff8585}.down{color:#5fd6ae}
+"""
+
+
+def render(metrics, meta):
+    total_comp = sum(m['竞对数'] for m in metrics)
+    share_avg = statistics.mean([m['份额指数'] for m in metrics]) if metrics else 0
+    below = [m for m in metrics if m['份额指数'] < 0.8]
+    issues = [(m, r) for m in metrics for r in judge(m) if r[1] in ('danger', 'warn')]
+
+    kpis = [('试点门店', f'{len(metrics)} 家'),
+            ('对标门店', f'{total_comp} 家'),
+            ('份额落后门店', f'{len(below)} 家'),
+            ('平均份额指数', f'{share_avg:.2f}')]
+
+    rows_html = []
+    for m in metrics:
+        j = judge(m)[0]
+        rows_html.append(f"""<tr>
+<td>{m['门店名']}</td><td>{m['竞对数']}</td>
+<td>{m['本店商品数']} / {m['竞对商品数']:.0f}</td>
+<td>{m['本店价']:.0f} / {m['同城价']:.0f}</td>
+<td class="{'up' if m['价格指数'] > 1.12 else 'down' if m['价格指数'] < 0.9 else ''}">{m['价格指数']:.2f}</td>
+<td>{pct(m['份额'])}</td>
+<td class="{'up' if m['份额指数'] < 0.8 else 'down' if m['份额指数'] > 1.2 else ''}">{m['份额指数']:.2f}</td>
+<td>{m['本店评分']:.1f} / {m['同城评分']:.1f}</td>
+<td>{m['竞争力']:.2f}</td>
+<td><span class="tag t-{j[1]}">{j[0]}</span></td>
+</tr>""")
+
+    cards_html = []
+    for m, (label, level, msg) in issues:
+        cards_html.append(f"""<div class="card {level}">
+<div class="h"><span class="nm">{m['门店名']}</span><span class="tag t-{level}">{label}</span></div>
+<div class="msg">{msg}</div>
+<div class="nums">在售 {m['本店商品数']} 品(同城中位 {m['竞对商品数']:.0f}) · 价格指数 {m['价格指数']:.2f} · 份额指数 {m['份额指数']:.2f} · 评分 {m['本店评分']:.1f}</div>
+</div>""")
+    if not cards_html:
+        cards_html.append('<div class="card good"><div class="h"><span class="nm">本期未命中异常规则</span></div></div>')
+
+    return f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>同城对标检测报告</title><style>{CSS}</style></head><body><div class="wrap">
+<h1>同城对标检测报告</h1>
+<div class="sub">采集日期 {meta['date']} · 口径为抖音前台公开可观测指标 · 仅用于试点验证</div>
+<div class="kpis">{''.join(f'<div class="kpi"><div class="v">{v}</div><div class="l">{k}</div></div>' for k, v in kpis)}</div>
+<h2><span class="bar"></span>命中异常的 {len(issues)} 项,按优先级处理</h2>
+<div class="cards">{''.join(cards_html)}</div>
+<h2><span class="bar"></span>对标明细(按份额落后排序)</h2>
+<table><thead><tr>
+<th>门店</th><th>对标店</th><th>在售品(本/中位)</th><th>价格中位(本/同城)</th><th>价格指数</th>
+<th>销量份额</th><th>份额指数</th><th>评分(本/中位)</th><th>竞争力</th><th>判定</th>
+</tr></thead><tbody>{''.join(rows_html)}</tbody></table>
+<div class="note">
+<b>指数说明</b> 份额指数 1.00 = 与同组门店平均份额持平;价格指数 1.00 = 与同城价格中位持平;竞争力指数 = 份额 50% + 口碑 30% + 价格 20%。<br>
+<b>口径局限</b> 销量来自前台「已售」累计值,含历史,单次采集不能代表当日销量;要算真实日增需连续两次采集取差值。曝光、转化率、核销率、退款率属商家私有数据,外部采集拿不到,本报告不涉及。<br>
+<b>试点性质</b> {'本页数据为模拟样例,用于确认字段与判定规则是否可用,不代表真实经营结论。' if meta['demo'] else '数据由实际采集生成。'}
+</div>
+</div></body></html>"""
+
+
+def main():
+    args = sys.argv[1:]
+    demo = '--demo' in args
+    rows, src = [], ''
+
+    if demo:
+        rows = build_demo()
+    else:
+        path = None
+        if '--file' in args:
+            path = args[args.index('--file') + 1]
+        else:
+            files = sorted(f for f in os.listdir(SNAP_DIR) if f.endswith('.csv')) if os.path.isdir(SNAP_DIR) else []
+            if files:
+                path = os.path.join(SNAP_DIR, files[-1])
+        if not path or not os.path.exists(path):
+            print('未找到采集快照 CSV。可先用 --demo 查看报告形态。')
+            sys.exit(1)
+        src = path
+        with open(path, encoding='utf-8-sig') as f:
+            for r in csv.DictReader(f):
+                r['团购价'] = float(r.get('团购价') or 0)
+                r['原价'] = float(r.get('原价') or 0)
+                r['已售'] = float(r.get('已售') or 0)
+                r['评分'] = float(r.get('评分') or 0)
+                rows.append(r)
+
+    metrics = analyse(rows)
+    if not metrics:
+        print('快照里没有可分析的数据,检查「门店类型」是否填了「本店 / 竞对」。')
+        sys.exit(1)
+
+    meta = dict(date=rows[0]['采集日期'] if rows else date.today().isoformat(), demo=demo)
+    os.makedirs(OUT_DIR, exist_ok=True)
+    suffix = '示例' if demo else meta['date'].replace('-', '')
+    out = os.path.join(OUT_DIR, f'对标检测报告_{suffix}.html')
+    with open(out, 'w', encoding='utf-8') as f:
+        f.write(render(metrics, meta))
+
+    print(f'报告已生成: {out}')
+    print(f'分析门店 {len(metrics)} 家, 竞对 {sum(m["竞对数"] for m in metrics)} 家')
+    for m in metrics[:5]:
+        print(f"  {m['门店名']}  份额指数 {m['份额指数']:.2f}  价格指数 {m['价格指数']:.2f}  {judge(m)[0][0]}")
+    if src:
+        print(f'数据源: {src}')
+
+
+if __name__ == '__main__':
+    main()

+ 258 - 0
对标试点/03_分析/detect.py

@@ -0,0 +1,258 @@
+# -*- coding: utf-8 -*-
+"""
+门店月度检测引擎 v0.1 —— 基于飞书「门店考核指标」官方基准线
+输入: 门店数据_10月.csv (每月从飞书表导出/更新)
+输出: 04_输出/门店检测报告_<月份>.html  +  门店检测清单_<月份>.csv
+
+阈值集中在 THRESH, 与飞书《门店考核指标》表一致; 口径如有调整, 只改这里。
+"""
+import csv
+import os
+import sys
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(os.path.dirname(BASE), '04_输出')
+
+# ---- 阈值(来自飞书「门店考核指标」表) ----
+THRESH = dict(
+    score_base=4.3, score_target=4.5,          # 门店评分: 基准4.3, 目标高于4.5
+    hx_rate_base=0.40, hx_rate_target=0.60,    # 核销率: 基准40%, 目标60%
+    hx_amt_base=12000,                         # 核销金额: 基准12000
+    tuDan=[0.0127, 0.0318, 0.0635],            # 商责退单率: 预警/一般/非常严重(小数)
+    touSu=[0.0011, 0.0028, 0.0056],            # 投诉率(小数)
+    chaPing=[0.0066, 0.0166, 0.0331],          # 差评率(小数)
+    reply_base=50,                             # 评价回复率(%)
+    visit_low=100,                             # 月访问过低线
+)
+LV = {'R3': 3, 'R2': 2, 'R1': 1, 'B': 0.6, 'T': 0.3}
+
+
+def f(v, pct=False):
+    try:
+        x = float(str(v).replace('%', '').replace(',', ''))
+        return x / 100 if pct else x
+    except (ValueError, TypeError):
+        return None
+
+
+def band(v, lines):
+    """返回违规等级 R1/R2/R3 或 None"""
+    if v is None:
+        return None
+    if v > lines[2]:
+        return 'R3'
+    if v > lines[1]:
+        return 'R2'
+    if v > lines[0]:
+        return 'R1'
+    return None
+
+
+def detect(rows):
+    out, seen, id_conflict = [], {}, []
+    for r in rows:
+        sid = r['门店ID'].strip()
+        if sid in seen:
+            if seen[sid] != r['门店名称']:
+                id_conflict.append((sid, seen[sid], r['门店名称']))
+            continue  # 重复行去重(保留首行)
+        seen[sid] = r['门店名称']
+
+        g = {k: f(r.get(k, ''), pct='率' in k or k == '评价回复率') for k in (
+            '门店页访问次数', '门店页成交券数', '门店核销金额', '门店核销券数',
+            '门店意向成交券数', '门店评分', '新增评价数', '新增中差评数',
+            '累计评价数', '累计中差评数', '评价回复率', '经营风险投诉率',
+            '经营风险商责退单率', '新发布门店关联视频数', '门店收藏数')}
+        if g['门店意向成交券数'] and g['门店意向成交券数'] > 0:
+            hx_rate = (g['门店核销券数'] or 0) / g['门店意向成交券数']
+        else:
+            hx_rate = None
+        if (g['新增评价数'] or 0) >= 10:
+            cp_rate = (g['新增中差评数'] or 0) / g['新增评价数']
+            cp_src = '新增'
+        elif (g['累计评价数'] or 0) >= 20:
+            cp_rate = (g['累计中差评数'] or 0) / g['累计评价数']
+            cp_src = '累计'
+        else:
+            cp_rate, cp_src = None, ''  # 样本太少, 不判差评率, 避免小样本误报
+
+        flags = []
+        lv = band(g['经营风险商责退单率'], THRESH['tuDan'])
+        if lv:
+            lv_name = {'R1': '预警线', 'R2': '一般严重线', 'R3': '非常严重线'}[lv]
+            flags.append((lv, f"商责退单率{g['经营风险商责退单率'] * 100:.2f}%超{lv_name}",
+                          '催办到店核销、主动处理退单商责, 避免平台处置'))
+        lv = band(g['经营风险投诉率'], THRESH['touSu'])
+        if lv:
+            flags.append((lv, f"投诉率{g['经营风险投诉率'] * 100:.2f}%超线", '24小时内响应投诉工单, 复盘服务环节'))
+        lv = band(cp_rate, THRESH['chaPing'])
+        if lv:
+            flags.append((lv, f"差评率({cp_src}口径){cp_rate * 100:.2f}%超线",
+                          '处理近30天差评, 主攻优质深度好评'))
+        if g['门店评分'] is not None and g['门店评分'] < THRESH['score_base']:
+            flags.append(('B', f"评分{g['门店评分']:.1f}低于考核基准{THRESH['score_base']}",
+                          '引导好评+回复差评, 目标先回4.3再冲4.5'))
+        if hx_rate is not None and hx_rate < THRESH['hx_rate_base']:
+            flags.append(('B', f"核销率{hx_rate * 100:.0f}%低于基准{THRESH['hx_rate_base'] * 100:.0f}%",
+                          '电话/私信触达待核销客户, 加速到店'))
+        if (g['门店核销金额'] or 0) < THRESH['hx_amt_base']:
+            if (g['门店核销券数'] or 0) == 0:
+                flags.append(('B', '本月零核销', '检查团购在售状态与价格, 重新起量'))
+            else:
+                flags.append(('B', f"核销金额{g['门店核销金额']:.0f}元低于基准{THRESH['hx_amt_base']}",
+                              '补齐在售品结构, 冲单量'))
+        if (g['评价回复率'] is not None and g['评价回复率'] < THRESH['reply_base'] / 100):
+            flags.append(('T', f"评价回复率{g['评价回复率'] * 100:.0f}%偏低", '安排专人回复全部评价'))
+        if (g['门店页访问次数'] or 0) < THRESH['visit_low']:
+            flags.append(('T', f"月访问仅{g['门店页访问次数']:.0f}次", '检查POI完整度, 点亮门店+更新AIGC素材'))
+        if (g['新发布门店关联视频数'] or 0) == 0:
+            flags.append(('T', '本月零视频', '后台AIGC工具定期产出图片/视频'))
+
+        sev = max((LV[lv] for lv, _, _ in flags), default=0)
+        out.append(dict(门店ID=sid, 门店名称=r['门店名称'], 城市=r['所在城市'],
+                        评分=g['门店评分'], 核销率=hx_rate, 核销金额=g['门店核销金额'] or 0,
+                        核销券数=g['门店核销券数'] or 0,
+                        商责退单率=g['经营风险商责退单率'] or 0, 投诉率=g['经营风险投诉率'] or 0,
+                        差评率=cp_rate, 差评口径=cp_src, 回复率=g['评价回复率'],
+                        月访问=g['门店页访问次数'] or 0, 新视频=g['新发布门店关联视频数'] or 0,
+                        flags=flags, sev=sev))
+    out.sort(key=lambda x: (-x['sev'], x['核销金额']))
+    return out, id_conflict
+
+
+def actions(m):
+    acts, seen = [], set()
+    for _, _, a in m['flags']:
+        if a not in seen:
+            seen.add(a)
+            acts.append(a)
+    return acts
+
+
+CSS = open(os.path.join(BASE, 'report_style.css'), encoding='utf-8').read() \
+    if os.path.exists(os.path.join(BASE, 'report_style.css')) else """
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:#0f1115;color:#e6e8ec;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
+     font-size:14px;line-height:1.65;padding:36px 40px 64px}
+.wrap{max-width:1180px;margin:0 auto}
+h1{font-size:22px;font-weight:600}.sub{color:#8b93a1;font-size:13px;margin-top:8px}
+.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:26px 0 30px}
+.kpi{background:#171a21;border:1px solid #252a34;border-radius:12px;padding:16px 18px}
+.kpi .v{font-size:24px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
+.kpi .l{color:#8b93a1;font-size:12px;margin-top:2px}
+.kpi.warn .v{color:#ff8585}
+h2{font-size:15px;font-weight:600;margin:34px 0 14px;display:flex;align-items:center;gap:8px}
+h2 .bar{width:3px;height:14px;background:#6aa9ff;border-radius:2px}
+table{width:100%;border-collapse:collapse;font-size:13px;font-variant-numeric:tabular-nums}
+th{text-align:left;color:#8b93a1;font-weight:500;font-size:12px;padding:9px 10px;border-bottom:1px solid #2a2f3a;white-space:nowrap}
+td{padding:10px;border-bottom:1px solid #1d212a;vertical-align:top}
+tr:hover td{background:#151920}
+.tag{display:inline-block;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:500;margin:2px 4px 2px 0;white-space:nowrap}
+.t-R3{background:rgba(255,60,60,.2);color:#ff6b6b}
+.t-R2{background:rgba(255,107,107,.14);color:#ff8585}
+.t-R1{background:rgba(240,168,60,.16);color:#f0b45c}
+.t-B{background:rgba(109,140,255,.14);color:#8fa8ff}
+.t-T{background:rgba(140,148,163,.14);color:#9aa3b2}
+.cards{display:grid;gap:10px}
+.card{background:#171a21;border:1px solid #252a34;border-left:3px solid #2f3745;border-radius:10px;padding:14px 18px}
+.card.R3{border-left-color:#ff3c3c}.card.R2{border-left-color:#ff6b6b}.card.R1{border-left-color:#f0a83c}
+.card .h{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.card .nm{font-weight:600;font-size:14px}
+.card .city{color:#6f7787;font-size:12px}
+.card .msg{color:#a8b0be;font-size:13px;margin-top:6px}
+.card .act{color:#5fd6ae;font-size:12px;margin-top:4px}
+.card .nums{color:#6f7787;font-size:12px;margin-top:6px;font-variant-numeric:tabular-nums}
+.note{color:#767e8c;font-size:12px;line-height:1.9;margin-top:14px}
+.note b{color:#9aa3b2;font-weight:500}
+"""
+
+
+def render(metrics, conflicts, month):
+    def cnt(p):
+        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']))
+
+    kpis = [('检测门店', f'{len(metrics)} 家'),
+            ('超违规预警线(退单/投诉/差评)', f'{cnt("R")} 家'),
+            ('评分低于基准4.3', f'{sum(1 for m in metrics if m["评分"] is not None and m["评分"] < THRESH["score_base"])} 家'),
+            ('核销率低于基准40%', f'{sum(1 for m in metrics if m["核销率"] is not None and m["核销率"] < THRESH["hx_rate_base"])} 家'),
+            ('本月零核销', f'{sum(1 for m in metrics if m["核销金额"] == 0)} 家')]
+
+    worst = [m for m in metrics if m['sev'] >= LV['R1']][:20]
+    cards = []
+    for m in worst:
+        top = max(m['flags'], key=lambda x: LV[x[0]])
+        cards.append(f"""<div class="card {top[0]}">
+<div class="h"><span class="nm">{m['门店名称']}</span><span class="city">{m['城市']}</span>
+{''.join(f'<span class="tag t-{lv}">{lb}</span>' for lv, lb, _ in m['flags'][:4])}</div>
+<div class="msg">{actions(m)[0]}</div>
+<div class="nums">评分 {m['评分']} · 核销率 {f'{m["核销率"]*100:.0f}%' if m['核销率'] is not None else '-'} · 核销金额 {m['核销金额']:.0f} 元 · 退单率 {m['商责退单率'] * 100:.2f}% · 月访问 {m['月访问']:.0f}</div>
+</div>""")
+
+    trs = []
+    for m in metrics[:150]:
+        tags = ''.join(f'<span class="tag t-{lv}">{lb}</span>' for lv, lb, _ in m['flags'])
+        trs.append(f"""<tr><td>{m['门店名称']}</td><td>{m['城市']}</td>
+<td>{m['评分'] if m['评分'] is not None else '-'}</td>
+<td>{f'{m["核销率"]*100:.0f}%' if m['核销率'] is not None else '-'}</td>
+<td>{m['核销金额']:.0f}</td><td>{m['商责退单率'] * 100:.2f}%</td><td>{tags}</td></tr>""")
+
+    conf_html = ''
+    if conflicts:
+        rows = ''.join(f'<div>· {sid}: {n1} / {n2} —— 两个门店共用一个ID, 需在主数据里核实</div>'
+                       for sid, n1, n2 in conflicts)
+        conf_html = f'<h2><span class="bar"></span>主数据风险:同 ID 不同名</h2><div class="cards"><div class="card warn">{rows}</div></div>'
+
+    return f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>门店月度检测报告 {month}</title><style>{CSS}</style></head><body><div class="wrap">
+<h1>门店月度检测报告 · {month}</h1>
+<div class="sub">检测规则来自《门店考核指标》官方基准线 · 覆盖 {len(metrics)} 家门店 · 完整清单见同名 CSV</div>
+<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>
+<h2><span class="bar"></span>优先处理:超违规线门店 TOP{len(cards)}</h2>
+<div class="cards">{''.join(cards)}</div>
+{conf_html}
+<h2><span class="bar"></span>检测明细(前 150 行,按严重度排序)</h2>
+<table><thead><tr><th>门店</th><th>城市</th><th>评分</th><th>核销率</th><th>核销金额</th><th>退单率</th><th>触发问题</th></tr></thead>
+<tbody>{''.join(trs)}</tbody></table>
+<div class="note">
+<b>规则口径</b> 核销率 = 核销券数 ÷ 意向成交券数;差评率优先用新增口径(本月新增中差评÷新增评价),本月无新增评价时回退累计口径并标注。<br>
+<b>等级说明</b> 红色=超出违规线(退单/投诉/差评三线),蓝色=低于考核基准,灰色=提升项。<br>
+<b>主数据</b> 本月去重 {sum(1 for _ in metrics)} 家,去重依据门店ID;同ID不同名的冲突单列在报告里,需要人工核实。
+</div></div></body></html>"""
+
+
+def main():
+    args = sys.argv[1:]
+    src = args[0] if args and args[0].endswith('.csv') else os.path.join(BASE, '门店数据_10月.csv')
+    month = '2026-10'
+    if '--month' in args:
+        month = args[args.index('--month') + 1]
+    rows = list(csv.DictReader(open(src, encoding='utf-8-sig')))
+    metrics, conflicts = detect(rows)
+    os.makedirs(OUT, exist_ok=True)
+    tag = month.replace('_', '')
+    html = os.path.join(OUT, f'门店检测报告_{tag}.html')
+    csvout = os.path.join(OUT, f'门店检测清单_{tag}.csv')
+    with open(html, 'w', encoding='utf-8') as fp:
+        fp.write(render(metrics, conflicts, month))
+    with open(csvout, 'w', encoding='utf-8-sig', newline='') as fp:
+        w = csv.writer(fp)
+        w.writerow(['门店ID', '门店名称', '城市', '评分', '核销率', '核销金额', '商责退单率',
+                    '投诉率', '差评率', '差评口径', '评价回复率', '月访问', '本月新视频', '触发问题', '建议动作'])
+        for m in metrics:
+            w.writerow([m['门店ID'], m['门店名称'], m['城市'],
+                        m['评分'] if m['评分'] is not None else '',
+                        f"{m['核销率']:.4f}" if m['核销率'] is not None else '',
+                        f"{m['核销金额']:.2f}", f"{m['商责退单率'] * 100:.2f}%", f"{m['投诉率'] * 100:.2f}%",
+                        f"{m['差评率'] * 100:.2f}" if m['差评率'] is not None else '', m['差评口径'],
+                        f"{m['回复率'] * 100:.0f}" if m['回复率'] is not None else '',
+                        f"{m['月访问']:.0f}", f"{m['新视频']:.0f}",
+                        ' | '.join(f'{lb}' for _, lb, _ in m['flags']),
+                        ' ;'.join(actions(m))])
+    print(f'报告: {html}')
+    print(f'清单: {csvout}')
+    print(f'检测 {len(metrics)} 家, 红线 {sum(1 for m in metrics if m["sev"] >= 1)} 家, 同ID不同名冲突 {len(conflicts)} 组')
+
+
+if __name__ == '__main__':
+    main()

+ 148 - 0
换机迁移说明.md

@@ -0,0 +1,148 @@
+# 门店检测工作台 · 换机迁移说明
+
+> 打包时间:2026-10-30
+> 数据现状:2026-10 周期,1409 家门店;考核阈值与反馈文案配置均已随包带走
+
+---
+
+## 一、新电脑需要什么
+
+只需 **Python 3.8 或更高版本**(Windows 官方安装包即可),**不需要 pip 安装任何第三方库**——后端是 Python 标准库写的,前端已本地化(断网也能跑)。
+
+安装 Python 时务必勾选 **Add python.exe to PATH**。
+
+检查是否装好:打开 PowerShell 或 CMD,输入
+
+```
+python --version
+```
+
+能显示 `Python 3.x.x` 即可。
+
+---
+
+## 二、怎么跑起来
+
+### Windows(原方案)
+
+1. 把压缩包解压到任意目录(建议放 D 盘,路径不要有空格更省事,例如 `D:\门店检测工作台\`)
+2. 进入 `store-ops` 文件夹
+3. 双击 **`启动工作台.bat`**
+4. 浏览器会自动打开 `http://127.0.0.1:8787`;窗口别关,关了服务就停
+
+窗口里同时会打印**局域网访问地址**(形如 `http://192.168.x.x:8787`),同事用这个地址访问。
+
+### macOS / Linux(2026-10-31 起支持)
+
+1. 把 `门店检测工作台` 文件夹放到任意目录
+2. 进入 `store-ops` 文件夹
+3. 双击 **`启动工作台.command`**;若系统提示「无法打开,因为来自身份不明的开发者」,右键点该文件 → 选「打开」→ 再点「打开」,只需一次
+4. 浏览器会自动打开 `http://127.0.0.1:8787`;终端窗口别关,关了服务就停
+
+换端口 / 只给本机用:
+
+```bash
+./启动工作台.command 9000       # 换端口
+./启动工作台.command --local    # 只允许本机访问
+```
+
+> macOS 自带 Python 3 即可(`python3 --version` 能显示 3.7+ 就行),无需 pip 安装任何库。
+> macOS 的防火墙放行:首次运行时系统会弹「是否允许 Python 接受传入连接」,点「允许」即可,不需要 `开放局域网访问.bat`(那是 Windows 专用)。
+
+### 其他电脑 / 手机访问不了时
+
+双击 `store-ops\开放局域网访问.bat`(会自动申请管理员权限),放行 TCP 8787 端口,一次即可。
+
+仍然不行就按顺序排查:
+
+1. 两台设备是否连同一个 WiFi / 路由器
+2. 公司 WiFi 是否开了 AP 隔离(客户端隔离)—— 开了同网段也互访不了,找网管关
+3. 本机 8787 是否被其他程序占用(改端口:`python backend/server.py --port 9000`)
+4. 新电脑的 IP 和旧电脑不同,地址以启动窗口打印的为准
+
+> ⚠️ 本服务**没有登录密码**,同一内网的人都能看数据和改配置。只在公司办公网用,别在公共 WiFi 上开。想只给本机用:`python backend/server.py --local`。
+
+---
+
+## 三、数据在哪
+
+`store-ops\data\app.db` —— **所有东西都在这一个文件里**:
+
+| 内容 | 说明 |
+|---|---|
+| 门店数据 | 2026-10 周期 1409 家门店的指标与日/月明细 |
+| 考核阈值 | 你改过的核销金额基准等配置 |
+| 反馈文案 | 43 个文案模板(含你自定义的「近30天成交…」概览行) |
+| 导入历史 | 每次导入的批次记录 |
+
+**迁移时最不能丢的就是这个文件**。想备份就单独复制它。
+
+### 每天怎么导新数据
+
+两种方式,任选:
+
+- 左侧菜单 **【导入数据】** → 选文件 → 开始导入
+- 或把文件直接丢进 `store-ops\data\inbox\`,20 秒内自动导入并归档到 `processed\`
+
+支持 `.csv` 和 `.xlsx`。老的 `.xls` 请先用 Excel 另存为 `.xlsx`。
+必须包含表头行,列名可以是数据表原始列名(门店ID、门店名称、核销券数…)。
+
+### 想清空重来
+
+关掉服务,删除 `store-ops\data\app.db`,重启即会重建空库。
+⚠️ 注意:**连同你调过的阈值和文案配置一起清空**,删之前先复制备份。
+
+---
+
+## 四、包里都有什么
+
+```
+门店检测工作台/
+├─ 换机迁移说明.md          ← 本文件
+├─ store-ops/               ← 正式项目(前后端分离,日常用这个)
+│  ├─ 启动工作台.bat        ← 双击启动
+│  ├─ 开放局域网访问.bat    ← 放行防火墙(管理员)
+│  ├─ README.md             ← 功能说明、规则口径、变量对照表
+│  ├─ backend/              ← Python 后端(rules.py 是检测规则唯一源)
+│  ├─ frontend/             ← 前端页面(含本地 ECharts,断网可用)
+│  └─ data/app.db           ← 全部数据与配置
+├─ 对标试点/                ← 早期静态演示版(历史留档)
+│  ├─ 02_采集脚本/collect_douyin.py
+│  ├─ 03_分析/              检测规则、报告生成脚本、10月原始数据
+│  └─ 04_输出/              静态 HTML 报告与工作台(双击可直接看)
+└─ 项目记录/
+   └─ 开发日志_2026-10-29.md ← 需求变更、bug 修复、口径决策记录
+```
+
+`对标试点\04_输出\门店工作台_202610.html` 是早期的单文件版本,**不启动服务双击也能看**,适合临时演示或当作备份视图。
+
+---
+
+## 五、换机后建议做一次自检
+
+启动后打开 `http://127.0.0.1:8787`,确认三件事:
+
+1. 顶部显示 **2026-10**、门店数 **1409**
+2. 四个 KPI 数字:超违规线 **469** / 评分不达标 **323** / 核销率不达标 **929** / 零核销 **276**
+3. 左侧【文案配置】→ 概览行模板仍是 `近30天成交{门店意向成交券数};核销券数{门店核销券数};退单券数{门店意向退款券数}`,预览能正常显示数字而不是占位符
+
+三项都对,说明数据、配置、文案全部完好。
+
+---
+
+## 六、常见问题
+
+**双击 bat 一闪而过**
+说明没找到 python。确认 `python --version` 可用;或在该窗口手动执行 `python backend\server.py` 看报错。
+
+**提示端口被占用(Address already in use)**
+换端口:`python backend\server.py --port 9000`,然后访问 `http://127.0.0.1:9000`。
+
+**页面能开但数据是空的**
+`data\app.db` 没带过来或路径放错。确认 `store-ops\data\app.db` 存在且大小约 500KB。
+
+**改了阈值/文案,重启后还在吗**
+在。都存 `app.db` 的 settings 表里。
+
+**能不能不装 Python**
+不能。后端是 Python 写的;如果不方便装,可以用 `对标试点\04_输出` 里的静态 HTML 应急,但没有导入、筛选、文案配置这些功能。

BIN
检测规则说明_20261102.docx


+ 96 - 0
检测规则说明_20261102.md

@@ -0,0 +1,96 @@
+# 门店检测工作台 · 检测规则说明
+
+> 数据周期 2026-11-02 · 1413 家门店 · 导出时间 2026-11-02 16:20
+
+## 一、核心口径
+
+- 核销率 = 核销券数 ÷ **门店页成交券数**
+- 待核销 = 门店页成交券数 − 核销券数(估算,未扣退款)
+- 差评率:新增评价 ≥10 用新增口径;否则累计 ≥20 用累计口径;都不足仍判定但最高只到预警线
+- 评分 0 分 = 未开分,仍计入「评分不达标」
+- 评价回复率:只在有新增评价时判定
+- **四层并行**:流量 / 转化 / 履约 / 口碑各自独立判定、互不压制;只有履约层内部「成交未核销 → 核销率低」按根因去重
+
+## 二、规则明细
+
+### ① 流量层 · 能不能被看到
+
+| ID | 档位 | 指标 | 判定条件 | 问题文案 | 整改动作 | 命中 |
+|---|---|---|---|---|---|---|
+| VISIT | 运营提升 | 访问过低 | 访问次数 < 100(含 0 访问) | 门店曝光访问偏低 | 1. 检查门店是否有违规限流<br>2.点亮门店+更新AIGC素材,增加门店投稿,提升门店曝光 | 161 家 |
+| VIDEO | 运营提升 | 视频量少 | 新发布门店关联视频数 < 10 | 门店发布视频数量少 | 传素材:后台 AIGC 内容定期更 定期使用后台的AIGC素材工具,上传并更新高质量的图片与视频内容。这是吸引用户点击、提升页面转化率的基础。 | 504 家 |
+| LIT | 运营提升 | 点亮偏低 | 门店点亮数 < 10 且 访问 > 0 | 门店点亮次数偏低 | 店内需放置优惠码物料,积极引导到店客户点亮门店 | 1298 家 |
+| RANK0 | 运营提升 | 未上榜 | 上榜信息为空 / 「-」,即好评榜、人气榜均未进入 | 未上榜(好评榜/人气榜均未进入) | 连续7天交易上翻 + 主攻好评冲本地榜单;上榜可改善平台限流 | 980 家 |
+| RANK_IN | 运营提升 | 仅入围 | 上榜信息只有「入围XX榜」,没有「第 N 名」 | 仅入围未进榜:入围邻水县美容美体好评榜 | 在已入围榜单上持续做好评与交易增量,把名次冲上去 | 155 家 |
+
+### ② 转化层 · 看到了有没有下单
+
+| ID | 档位 | 指标 | 判定条件 | 问题文案 | 整改动作 | 命中 |
+|---|---|---|---|---|---|---|
+| DEAL0 | 考核未达标 | 零成交 | 访问 > 0 且 门店页成交券数 = 0 | 有11次访问却零成交 | 检查团购是否在售、门店是否有违规限流 | 54 家 |
+| CVR | 运营提升 | 转化率低 | 访问 ≥100 且 成交券数 > 0 且 成交券数 ÷ 访问 < 10% | 成交转化率仅2.5% | 提高门店基建数据,增加进店转化 | 1198 家 |
+
+### ③ 履约层 · 下单了有没有核销
+
+| ID | 档位 | 指标 | 判定条件 | 问题文案 | 整改动作 | 命中 |
+|---|---|---|---|---|---|---|
+| ZERO | 考核未达标 | 成交未核销 | 门店页成交券数 > 0 且 核销券数 = 0(履约层内命中此项则不再判「核销率低」) | 成交5张券一张未核销 | 触达全部已成交客户确认到店意愿,核查门店核销流程是否卡单 | 213 家 |
+| HX | 考核未达标 | 核销率不达标 | 门店页成交券数 ≥ 5 且 核销率 < 40% | 核销率3%(基准线40%) | 每天使用客资中心邀约未核销客户,设置到店核销激励 | 672 家 |
+| AMT | 考核未达标 | 客单价偏低 | 核销金额 < 10,000 元(独立判定,不受核销率约束) | 当月核销270元,未做大单上翻 | 每月至少完成1.2w大单上翻,保持金牌门店,获取平台流量激励 | 1287 家 |
+| PEND | 运营提升 | 未核销积压 | 估算未核销券(门店页成交券数 − 核销券数)≥ 10 张 | 积压32张未核销券 | 每天使用客资中心及时邀约新增客资 | 858 家 |
+
+### ④ 口碑层 · 体验好不好
+
+| ID | 档位 | 指标 | 判定条件 | 问题文案 | 整改动作 | 命中 |
+|---|---|---|---|---|---|---|
+| TD | 违规红线 | 退单率超线 | 商责退单率 > 1.27% / 3.18% / 6.35%(预警 / 一般严重 / 非常严重) | 商责退单率2.50%超预警线 | 调整门店接待流程避免商责退单进一步加重,主动补20单核销好评,降低商责退单占比 | 165 家 |
+| TS | 违规红线 | 投诉率超线 | 投诉率 > 0.11% / 0.28% / 0.56%(三级) | 投诉率2.70%超非常严重线 | 调整门店接待流程避免客诉进一步加重 | 9 家 |
+| CP | 违规红线 | 差评率超线 | 新增评价 ≥10 → 新增口径;否则累计评价 ≥20 → 累计口径;都不足仍判定但最高只到预警线;超 0.66% / 1.66% / 3.31% | 差评率(累计口径)1.96%超一般严重线 | 逐条处理近30天差评,主攻优质深度好评(避免无效评价) | 350 家 |
+| SCORE | 考核未达标 | 评分不达标 | 评分 < 4.3(0 分按「未开分」处理,文案与动作单独走) | 评分4.1(基准线4.3)-低于平台准入 | 优先引导带图深度好评,需先将门店评分提升至4.3以上 | 327 家 |
+| REPLY | 考核未达标 | 回复率偏低 | 新增评价 ≥1 且 评价回复率 < 50% | 新增5条评价,回复率0%偏低 | 安排时间定时回复新增评价,差评24小时内及时处理 | 834 家 |
+| RVOL | 运营提升 | 新增评价少 | 新增评价数 < 10 | 近30天新增评价少 | 每月保持不低于15条新增优质评价 | 1164 家 |
+
+## 三、可调阈值
+
+| 参数 | 当前值 | 出厂值 |
+|---|---|---|
+| score_base | 4.3 | 4.3 |
+| score_target | 4.5 | 4.5 |
+| hx_rate_base | 0.4 | 0.4 |
+| hx_rate_target | 0.6 | 0.6 |
+| hx_amt_base | 10000.0 | 12000 |
+| tuDan | [0.0127, 0.0318, 0.0635] | [0.0127, 0.0318, 0.0635] |
+| touSu | [0.0011, 0.0028000000000000004, 0.005600000000000001] | [0.0011, 0.0028, 0.0056] |
+| chaPing | [0.0066, 0.0166, 0.0331] | [0.0066, 0.0166, 0.0331] |
+| reply_base | 50.0 | 50 |
+| visit_low | 100.0 | 100 |
+| cvr_low | 10.0 | 10 |
+| pend_high | 10.0 | 30 |
+| hx_min_deal | 5.0 | 5 |
+| amt_min_coupons | 5.0 | 5 |
+| cp_min_new | 10.0 | 10 |
+| cp_min_total | 20.0 | 20 |
+| reply_min_new | 1.0 | 1 |
+| video_low | 10 | 10 |
+| lit_low | 10 | 10 |
+| new_review_low | 10 | 10 |
+
+## 四、诊断树(主因判定顺序)
+
+| 顺序 | 命中条件 | 含义 | 处理方向 |
+|---|---|---|---|
+| 1 | ① 有 R 级违规(退单 / 投诉 / 差评) | 平台已在处置或限流 | 先止血:处理违规项,再谈增长 |
+| 2 | ② 零访问 / 访问过低 | 没有流量进来,后面几步都无从谈起 | 先修曝光(点亮 + 素材 + 榜单) |
+| 3 | ③ 零成交 / 转化率低 | 有流量但没下单 | 修转化(品项、价格、主图) |
+| 4 | ④ 成交未核销 / 核销率低 / 核销金额低 | 客户买了不到店,或到店不核销 | 修履约(集中邀约 + 核销激励) |
+| 5 | ⑤ 评分不达标 / 回复率低 | 口碑拖累自然流量与转化 | 修口碑(处理差评 + 专人回复) |
+| 6 | ⑥ 以上都不命中 | 本期达标 | 保持动作,关注冲刺目标 |
+
+## 五、输出结构
+
+| 字段 | 说明 |
+|---|---|
+| 一句话结论 | 按诊断树选出的主因生成,如「卡在「履约」:核销率 11%(基准线 40%)」 |
+| 主因 | 诊断树从上往下第一个命中的问题 |
+| 全部问题 | 命中几个列几个,不再截断 |
+| 整改动作 | 每个问题一条对应动作,去重后输出 |

+ 218 - 0
项目记录/开发日志_2026-10-29.md

@@ -0,0 +1,218 @@
+# 2026-10-29 门店检测工作台项目启动
+
+## 项目背景
+用户是抖音美容门店本地生活运营,品牌下 1000+ 门店,要做「门店数据检测工作台」。当前在预研对齐阶段,未开始正式开发。
+
+## 已对齐的关键决策
+- 同城对标范围:每店 3-5 家核心竞对(督导指定,去重后约 3000-4000 家)
+- 数据获取路线:先用半自动跑 10 家门店试点,验证字段有效性后再决定是否全量
+- 双轨对比框架:轨道A = 品牌 1000+ 店内部横比(全指标,数据用户提供);轨道B = 同城外部门店对比(仅前台公开可观测指标,需线上采集)
+- 采不到的数据(明确告知):曝光/UV/转化率/核销率/退款率是商家私有数据
+- 可行方法:已售增量法(每天记录「已售」取差值)、评价增量法(相对比较有效,绝对单量不可信)
+- 采集量控制:全量对标店周采做基线,异常门店(约100家/天)日采
+
+## 已产出(D:/WorkbuddySpace/门店检测工具/对标试点/)
+- 01_对标店清单_模板.csv:50 行(10 店 × 5 竞对),待用户填门店链接
+- 02_采集脚本/collect_douyin.py:调本机 Chrome 无头 --dump-dom,无需 Playwright;--dump 抓页面 / --parse 生成快照;parse 规则是草稿,需真实页面校准
+- 03_分析/build_report.py:纯标准库,--demo 可跑;输出 04_输出/对标检测报告_*.html
+- 报告指标:份额指数(份额×(竞对数+1))、价格指数(本店价中位/同城价中位)、评分差、竞争力指数(份额50%+口碑30%+价格20%)
+- 检测规则:定价偏高 / 性价比未被感知 / 品项不足 / 口碑落后 / 优势店 / 持平
+
+## 环境备注
+- Python 3.13(无 pandas/openpyxl/playwright),Node 22
+- 本机有 Chrome 和 Edge,无头截图/渲染可用(已验证)
+
+## 待办 / 下一步
+- 用户填 01 清单:10 家本店 + 各 3-5 家竞对的抖音链接
+- 拿到链接后先跑 1-2 个 --dump 校准 parse_page 的选择器
+- 风险提示已给:门店换绑 POI/改名导致 ID 断链,需内部门店编码兜底
+
+---
+
+## 下午:飞书数据源打通 + 首版真实检测报告(重大进展)
+
+### 飞书连接器(lark-cli)
+- 连接器名「飞书」,CLI 位于 ~/.workbuddy/binaries/node/cli-connector-packages/lark-cli
+- 应用 cli_aa68507f493adcf5,已批权限:wiki 三个 + sheets:spreadsheet:read;知识库空间 7699703484173552912(怡呵美抖音云验舱)已把应用加为成员
+- 授权流程:lark-cli config init --new 后台跑 → 出验证链接 → 用户浏览器完成;scope-apply 链接给用户批权限
+- 读表命令:lark-cli sheets +cells-get --spreadsheet-token <tok> --sheet-id <sid> --range A1:Z1425 --output-path xx.json(JSON 顶层直接是 ranges,无 ok/data 包装);--jq 取值
+
+### 三张表(space 7699703484173552912)
+1. 门店评估后反馈格式(docx, SJGbdfyBdoDB2ux07gac5BuRnHb):门店评估报告成品模板——经营概览/风险限流因素/待核销/流失分析/流失去向竞对清单/洞察/调整动作(评价·AIGC素材·点亮门店·冲榜单·7天交易上翻核销≥12000)
+2. 门店考核指标(sheet, BXKjsuG9bhnFlbtOXZEcq1rDnTe):官方基准线——评分基准4.3目标4.5、经营分100/120、牌级铜/金、核销率40%/60%、核销金额12000、商责退单率1.27/3.18/6.35%、投诉率0.11/0.28/0.56%、差评率0.66/1.66/3.31%
+3. 10.1-10.28门店数据(sheet, T5WTsibjchPW9VtQ5XbcaEJnncb):26列×1424行,含门店ID(抖音ID)唯一键、访问/成交/核销/评价/风险率/点亮/收藏/上榜
+
+### 数据体检结论
+- 1424 行去重后 1409 家门店(15 行重复),覆盖 242 城(长沙65/郑州48/武汉40)
+- 13 个重复ID,其中 4 组【同ID不同名】需用户核实(如 7664429404466759732 希尔顿店/家润多广场店共用)
+- 月核销金额中位仅 161.9 元:0核销 283 家、≥12000 仅 107 家(长尾极重)
+- 评分有 0 分(疑似未开分),0 分店会被判"低于4.3",口径待确认
+- 反馈文档样例店核销率 37.78% vs 表算 16/45=35.6%,核销率官方口径待确认(暂用 核销券数÷意向成交券数)
+- 经营分/牌级两列不在月度数据表里,考核表有基准但无法检测——缺口
+
+### 已产出
+- 03_分析/detect.py:月度检测引擎,阈值集中在 THRESH(与考核表一致),差评率带小样本保护(新增≥10或累计≥20才判)
+- 04_输出/门店检测报告_2026-10.html + 门店检测清单_2026-10.csv(全量1409家,按严重度排序,含建议动作)
+- 10月结果:超违规预警线450家(退单率预警66/严重61/非常严重38,差评率327),评分<4.3 323家(含0分店),核销率<40% 929家,零核销276家
+- 踩坑记录:飞书比率字段解析后是小数,阈值必须同单位(曾因单位不匹配+差评率小样本误报出"100%超线")
+
+### 用户偏好确认
+- 用户对齐推进顺畅,愿意配合授权操作;沟通中"开通好啦/加好了"节奏快,适合给明确的单步指令
+
+### 可视化工作台(用户明确要求"以可视化的工作台形式展示")
+- 03_分析/build_dashboard.py:读 门店数据_10月.csv → 复用 detect 检测 → 生成 04_输出/门店工作台_202610.html(单文件 706KB,内嵌全量 1409 家数据)
+- 功能:5 张可点击 KPI 卡(点击即筛选)、3 张 ECharts 图(问题命中分布/核销金额分布/城市TOP15)、搜索+城市+问题类型+排序筛选、分页表格、点击行开详情抽屉(指标条带基准线/目标线刻度+问题+建议动作+一键复制"反馈格式"文案)
+- 链接直达:URL 加 #门店ID 直接打开该店详情抽屉(可发给督导)
+- ECharts 走 jsdelivr CDN,离线时表格和 KPI 仍可用(有 window.echarts 守卫)
+- 无头截图注意:CSS transition 会导致抽屉截图时机问题,用 --dump-dom 验证 class="drawer on" 更可靠
+
+### 品牌配色(用户提供怡呵美 logo:金色系)
+- 工作台主题改为深棕暖底(#14100a)+金色主色(#e8a020/#f0b23e)+暖白文字(#f5efe2)
+- 语义色保留:红系=超线(R1-R3),淡金(#e6cf6e)=低于基准(B),灰=提升项(T),暖绿(#58c08c)=达标
+- 头部加了品牌徽章「怡呵美·水光会员店」,复制按钮改为金色实底 CTA
+
+### 第三版配色:明亮奶油风(用户提供参考截图,明确要求)
+- 用户偏好明亮配色(参考某轻风格应用:奶油底+白色圆角卡片+柔和粉彩),弃用深棕金主题
+- 当前主题:底 #f6f4ef / 卡片白 #fff 圆角16px 轻投影 / 主蓝 #5b87e8 / 文字 #33333d
+- 语义色:红 #e05a48(红线) / 橙 #f2a65a / 黄 #f0b45c / 蓝 #7fa5ec(低于基准) / 绿 #6fc49a(达标);标签用同色淡底
+- 用户问"为什么只出 html"——已解释:原型期口径未定,单文件最快;定稿后可部署上线带权限
+
+### v3:右侧菜单栏「考核指标」面板(可自定义阈值)
+- build_dashboard.py 重写 v3:右侧悬浮菜单栏(考核指标 + 导出清单),面板内 8 组阈值全部可编辑
+- 检测逻辑搬到前端 JS(与 detect.py 同规则同步实现),改阈值后 KPI/图表/表格/详情/反馈文案全部重算
+- 数据 JSON 只存原始字段(去掉 flags),文件从 707KB 降到 365KB;detect.py 补输出「核销券数」用于零核销判定
+- 自定义阈值存 localStorage(key=yhm_thresh_<月份>),恢复默认一键还原;rail 有红点标记"已自定义"
+- 校验方式:改默认阈值生成副本 → 无头 dump-dom 抓 KPI → 与 Python detect 同阈值结果对比(650/1068 全 PASS)
+- 面板交互:hi 遮罩层(z11)+点遮罩/×/Esc 关闭;#TH hash 直达面板;apply 前做递增/目标>基准校验
+- 新增导出清单:当前筛选结果 → CSV(带BOM),文件名标注是否自定义阈值
+
+### 环境:ego lite(用户要求用 ego 浏览器打开渲染页)
+- 已安装:C:\Program Files\Citro Labs\ego lite\Application\ego.exe(v0.6.1.13),已在运行
+- 但 ego-browser CLI 未注册到 PATH(%LOCALAPPDATA%、~/.local/bin、npm global 均无),ego-browser skill 无法调用
+- 打开页面方式:Start-Process ego.exe "file:///D:/.../门店工作台_202610.html" → 成功,窗口标题=页面标题
+- 无 CDP 调试端口(netstat 无监听),桌面截图被 Add-Type 安全策略拦截,故无法自动截 ego 窗口
+
+### 正式项目:store-ops 前后端分离本地版(用户要求"做成本地项目,前后端分离,每天导数据")
+- 位置 D:\WorkbuddySpace\门店检测工具\store-ops\;backend(标准库http.server+sqlite3,零依赖) / frontend(原生SPA+本地echarts vendor) / data(app.db+inbox+processed)
+- 规则唯一源 backend/rules.py(阈值默认值+检测+校验);db.py 聚合;ingest.py CSV导入;server.py 路由+收件箱watcher(20s)
+- 关键口径:核销率=核销券/意向成交券;差评率新增≥10否则累计≥20否则不判;评分取最近有值(含0分);同文件重复行保首行;同月日数据≥15天才切日粒度,否则用月汇总
+- API:/api/meta /api/dashboard /api/thresholds(PUT/reset) /api/import /api/export.csv /api/history;深链接 #TH #IM #门店ID
+- 验证:KPI 450/323/929/276 与 detect.py 完全一致;阈值4.6→650家;422校验;收件箱自动导入;导1天日报不破坏月视图
+- 启动:双击 启动工作台.bat(GBK编码避免乱码)或 python backend/server.py --port 8787
+- 待办:自动下载(来客API/服务商)、处理闭环(整改回填)、权限分层
+
+### v4:菜单栏从右侧移到左侧,入口改名「指标配置」
+- 用户截图标注左侧要「数据指标配置」菜单 → .rail 改为 left:0(圆角/阴影镜像翻转),.wrap 左内边距 62px 给菜单让位
+- 入口文案:考核指标 → 指标配置(面板标题同步),空状态提示「点左侧【导入数据】」
+- 功能不变:点开右侧滑出指标配置面板,8 组阈值可改,应用后服务端重算
+
+### v5:导入支持 Excel(.xlsx)
+- 新增 backend/xlsx.py:标准库 zipfile+XML 解析 xlsx(共享字符串/日期单元格序列号转 YYYY-MM-DD/多 sheet),仍零依赖(openpyxl 仅测试时 pip 装了,用于造测试文件)
+- ingest.py 重构:import_rows(rows,...) 为唯一入库核心,CSV/xlsx 共用表头别名与幂等逻辑;import_bytes 按扩展名分发;自动跳过表头前的杂讯行
+- server.py:/api/import 与收件箱 watcher 均支持 .xlsx;前端 accept/文案更新;旧版 .xls 明确提示另存为 .xlsx
+- 测试:整月 xlsx 1409 行=CSV 完全一致(15 重复跳过/26 列匹配);日报 xlsx 字符串日期"2026/10/29"、真日期单元格、"80%"→0.8 均正确;测试数据已清理,KPI 恢复 450/323/929/276
+
+### 修复:门店详情抽屉不弹出
+- 原因:app.js 中 metricsOf 返回的 m.v 是数值,openDrawer 却写成了 m.v(s) 函数调用 → TypeError,抽屉渲染中断
+- 修复:openDrawer 改为 typeof m.v==='function' ? m.v(s) : m.v;dump-dom 验证 7 条指标条/17 刻度/复制按钮齐全,截图确认弹出
+- 教训:v3 重写前端时引入的回归,测试时只验证了 #TH 面板没验证 #门店ID 抽屉
+
+### 差评率口径变更(用户要求去掉样本不足限制)
+- 旧:新增评价≥10 用新增口径;否则累计≥20 用累计口径;都不足→不判定
+- 新:新增评价≥10 用新增口径;否则累计有评价就用累计口径;只有新增无累计也判;完全无评价(15家)才不判
+- ⚠️ 教训:不能简单把阈值 10/20 改成 >0——那会让新增 1-9 条的店从累计口径切成新增口径,R 反而 450→321(更宽松)。正确做法=只去掉"不判定"限制,口径优先级不变
+- 结果:差评率超线 327→329,R 450→469(+19 家小样本门店被新判入)
+- 面板提示文案同步更新;抽屉差评率空值显示「无评价」
+
+## 17:2x 分页改为每页 10 条
+- store-ops/frontend/app.js:PAGE_SIZE 50 → 10(分页控件原有,无需改 HTML)
+- 无头 Chrome 验证:主页渲染 10 行,pginfo「第 1 / 141 页」,共 1409 家
+
+## 17:4x 新增「文案配置」功能
+- 需求:反馈文案可自定义,不再千篇一律
+- rules.py:所有文案抽成 DEFAULT_TEXTS(31 个模板),detect() 用模板渲染,flags 增加第 4 位稳定规则 key,问题分类改由 key 映射(改文案不影响筛选)
+- db.py:texts 存 settings 表(JSON),get/set/reset
+- server.py:/api/copytext GET/PUT/reset;dashboard payload 带 texts + text_defaults
+- 前端:左侧栏最上方新增【文案配置】菜单(#TX 深链接),4 组面板(反馈结构8/档位名3/问题文案10/整改建议10),改动高亮、恢复默认
+- 变量替换 fmt_tpl:未知占位符原样保留;fb_line_overview 支持 {门店名}{城市}{周期}{访问}{金额}{评分}{核销率}{核销券数}{成交券数}
+- 修复:t_TS/t_CP 模板缺 {档} 变量注入
+- 验证:KPI 无回归(469/323/929/276),PUT→全店重算→reset 全链路 PASS,无头浏览器 31 字段渲染 PASS
+
+## 17:5x 文案面板新增实时预览
+- 面板顶部新增「实时预览」块:以问题最多的门店为例,边改模板边看渲染结果,应用后才保存
+- app.js:抽出 renderFeedback(s,X,flags) 供复制与预览共用;previewFlags 仅预览用(注释声明规则唯一源仍在 rules.py)
+- 修复双百分号:核销率/回复率文案 v 变量改纯数字(前后端同步),输出恢复「核销率0%低于基准40%」
+- 无头浏览器验证预览全文渲染 PASS,KPI 无回归
+
+## 10:4x 反馈文案按用户真实格式重构
+- 用户提供门店实际反馈文案样例,按其 6 段式重构默认文案:概览(客资/核销/退单)→ 风险因素 → 待核销订单行(>0 才显示)→ 顾客流失分析(访问/成交/流失+占比)→ 分析洞察(按条件取舍 5 条)→ 📌门店调整动作(固定动作清单,textarea 多行编辑)
+- rules.py DEFAULT_TEXTS 扩到 42 键:去掉 fb_sep/fb_sec_act/fb_item_act,新增 fb_line_pending、fb_sec_loss、fb_item_visit/deal/lost、fb_sec_insight、fb_item_insight、fb_insight_metric/score/flow/order/reply/priority、fb_sec_action、fb_actions
+- 洞察条件:指标层面=核销率不达标;口碑分=评分不达标;流量转化=有访问;订单风险=待核销>0;口碑管理=回复率偏低;整改优先级=有风险项
+- 新增变量:客资/核销/退单/待核销/成交/流失/成交占比/流失占比/评分基准/核销金额基准;访问环比暂为 '-'(无上月数据)
+- db.py aggregate_period 输出补 deal、deal_refund;server.py payload 透传
+- t_HX/t_SCORE 默认改成用户风格(核销率{v}%(基准线{base}%)/ 评分{v}(基准线{base})-低于平台准入)
+- 清除 settings 里旧 texts 存档使新默认生效
+- 验证:Node DOM-stub 集成测试跑通 app.js 真实渲染(有风险店+无问题店两例输出正确);KPI 无回归。Chrome 无头本次卡死(单例锁),以后用 node harness 或 ego 浏览器
+
+## 13:5x 详情抽屉改「预览分析结果」弹窗
+- 一键复制按钮 → 预览分析结果:弹出居中弹窗,textarea 展示反馈文案全文,可手动修改(不保存),【重新生成】还原,【复制定稿文案】复制当前编辑内容
+- Esc/点遮罩/× 只关预览不关抽屉;closePanels 联动关闭
+- Node harness 验证 openDrawer→openPreview→修改保留 全链路 PASS
+
+## 14:1x UI 全面重构(简洁高级)
+- 设计系统:暖中性灰画布 #f4f3f0 + 纯白卡片 + 发丝描边 #e9e7e1;墨黑主操作按钮;黄铜金 #8a6a2f 品牌点缀;低饱和数据色(玫瑰/琥珀/沙金/苔绿/灰蓝)
+- 重写 style.css:CSS 变量 token 化、字距/行高统一、卡片半径 12/16、hover 仅描边+轻投影、侧面板分栏与滚动条细化、toast/弹窗统一
+- header 改两行结构(品牌+标题 / 规则·周期信息,周期选择右侧独立)
+- KPI 数字单位弱化(.v .u),选中态墨黑描边
+- 抽屉改 dwhead(sticky)+ dwbody 结构,新增右上角 × 关闭(原来只能点遮罩/Esc)
+- 修复:spfoot 主按钮被 .spfoot button 覆盖成白底白字(应用到工作台/开始导入不可见)
+- 图表配色与抽屉 barColor/sevColor 同步换成低饱和palette
+- 截图验证(Edge headless,1440 与 900 宽):首页/抽屉/指标面板/导入面板均正常;Node harness 回归 PASS
+- 注意:Chrome headless 在本机会卡死(单例锁),截图改用 Edge:msedge --headless --screenshot --virtual-time-budget=12000
+
+## 14:2x 配色改为香槟金主调(参考品牌金黄图)
+- 用户反馈上一版中性灰"配色更单调",提供品牌图(饱和金黄+白)要求多用金色
+- style.css 全量换色:画布 #f8f4ea 香槟底 + 顶部三处金色径向光雾(body 多背景,background-attachment:fixed);描边 #ecdfc2 金调;文字改暖棕系(#211a10/#5c5241/#93876f)
+- 金色 token:--gold #c8952c / --gold-deep #a4761c / --gold-bg #fbf4e2 / --grad-gold 三段渐变
+- 金色落地:brand 标签、railitem.on、rail hover、pill、primary/copybtn/spfoot/pvfoot 主按钮(渐变+暖金投影+内高光)、KPI 卡顶部 2.5px 金条(hover 全亮)、选中 KPI 金底金边、表头 #fdf9ee+金分隔、panel h3 金竖条、thgroup 金竖条、输入聚焦金边+金晕、预览块左侧金边、drop 拖拽区、pager hover、抽屉头 3px 金线+淡金渐变头、弹窗头尾淡金渐变、toast 深棕+金边
+- header 底部改局部金色渐变细线(左实右隐)
+- 标签色提对比:t-R3/R2/R1/B/T 全部换成暖金棕色阶(上一版米色太淡看不清)
+- app.js 图表:ECharts 纯 JSON linear gradient(GOLD_BAR,避免 echarts.graphic 依赖,harness 也能跑);c2 四档砖红/琥珀/沙金/苔绿;c3 沙金;sevColor/barColor 同步暖化;残留灰字 #8b8a86/#a5a29a → #93876f/#a1957d
+- 验证:Node harness 回归 PASS(预览/复制逻辑不变),Edge 无头截图首页/抽屉/文案面板均正常,截图存 .workbuddy/ui_gold.png
+
+## 14:36 预览弹窗放大
+- .pvcard:560px 固定宽 → width:min(980px,84vw)、height:min(760px,84vh)(面积约占屏幕 57%);内边距与外层留白同步加大(wrap padding 32、头尾 20/28)
+- textarea 字号 12.5→13px、行高 2→2.05、padding 18/22→22/28;按钮加高
+- 用临时 file:// 页面 + Edge 无头截图验证:1080 宽视口下弹窗占宽约 68%,长文案一屏可见
+
+## 14:45 新增「按评分」筛选下拉
+- index.html 筛选栏在「全部城市」后加 fscore:全部评分 / 4.0以下 / 【4.0-4.3】 / 【4.3-4.5】 / 4.5以上
+- app.js:state.score、scoreInBand(v, band)(先 round 到 1 位防浮点误差)、filtered() 叠加筛选、bind() 绑定、renderFilters 回填、导出 URL 带 score
+- 闭区间按【】语义含端点:4.3 同属 4.0-4.3 与 4.3-4.5,4.5 同属 4.3-4.5 与 4.5以上(用户明确要求;两档合计会大于总店数属正常)
+- server.py build_export 支持 score 参数,口径与前端一致
+- 验证(1409 家):4.0以下 101 / 4.0-4.3 334 / 4.3-4.5 327 / 4.5以上 850;4.3 在两档各 112、4.5 在两档各 91;4.0以下档无 >=4.0 漏入;导出 CSV 行数与前端完全一致
+- harness:.workbuddy/wt_test_score.js
+
+## 14:55 支持局域网访问
+- server.py:默认绑定 0.0.0.0(原来 127.0.0.1),新增 --local 只绑本机;启动时 lan_ips() 打印所有局域网 IPv4 地址(UDP connect 8.8.8.8 取默认出口 + getaddrinfo 补充,过滤 127.*)
+- 新增 开放局域网访问.bat(GBK):自检管理员,非管理员以 RunAs 方式重新拉起自身;netsh 先删后加入站规则「门店检测工作台 8787」TCP 8787 profile=any
+- 启动工作台.bat:加 chcp 936,for /f 取 LAN IP 并打印局域网访问地址,提示防火墙脚本
+- README 增加「同一局域网的其他电脑/手机怎么访问」:地址、放行步骤、AP 隔离/跨网段排错、无鉴权安全提醒、--local 关闭
+- 本机现状:网络为公用(Public),已有 python.exe 入站放行规则(公用/TCP/任意端口),恰好匹配服务进程 C:\...\.workbuddy\binaries\python\versions\3.13.12\python.exe → 外部大概率已可直连,无需再放行
+- 验证:LAN IP 192.168.10.232 下 /api/meta、/api/dashboard(1409)、/api/export.csv 均 200;前端无硬编码 127.0.0.1/localhost(全相对路径)
+- 坑:Bash 命令文本里出现那个 shell 名字会被安全策略拦截,涉及 bat 生成改用写 .py 文件再执行
+
+## 15:40 修复「改文案变量不渲染」
+- 现象:用户把概览行写成 `近30天成交{门店意向成交券数};核销券数{门店核销券数};退单券数{门店意向退款券数}`,预览原样输出占位符
+- 根因:fbVars 只认短名(客资/核销/退单),用户按数据表列名写的全称变量不在表里 → fmtTpl 原样保留
+- 修复 app.js:
+  1. 新增 VAR_ALIAS 别名表(门店意向成交券数→客资、门店核销券数→核销券数、门店意向退款券数→退单、门店页访问次数/门店页有效人数→访问、门店实际成交人数→成交、核销金额→金额、门店评分/团购评分→评分、评价回复率→回复率 等 30 项),fbVars 末尾展开别名
+  2. unknownVars() + knownVarSet()(BASE_VARS ∪ VAR_ALIAS ∪ 保留变量 i/问题/建议/洞察/v/base/target/口径/档)
+  3. 面板每字段加「+ 变量」按钮 → 展开变量 chips,点选插入光标处(insertAtCursor)
+  4. 字段含未知变量 → .thfield.bad 红框 + .varwarn 文字提示;预览区 txBad 红条汇总 + .badvar 高亮残留 {...}
+  5. applyTexts 保存时若有未知变量,toast 提示但允许保存
+  6. 计数类变量改用 cnt()(无千分位),金额仍 money()(千分位)
+- 验证(Node harness .workbuddy/wt_test_var.js):用户原模板 → `近30天成交8;核销券数0;退单券数15`,无残留花括号;短名等价;写错 `{核消券数}` 能被检出;默认文案 43 个字段无未知变量
+- Edge headless/#TX:43 个 varbtn/varchips/varwarn 就位,txBad display:none(无错),预览正确渲染
+- README 增加变量对照表(短名 ↔ 数据表列名别名)
+- 注意:库里已存用户版模板,别名生效后直接可渲染,无需重存

+ 88 - 0
项目记录/开发日志_2026-10-31.md

@@ -0,0 +1,88 @@
+# 2026-10-31 迁移到 macOS + 验收修复
+
+## 背景
+从 Windows(Workbuddy 环境,`D:\WorkbuddySpace\门店检测工具\`)迁到 macOS。
+迁移包 `门店检测工作台_迁移包_20261030.zip` 解压到本机后接续开发。
+
+## 环境
+- macOS,系统自带 Python 3.9.6(`/usr/bin/python3`),零第三方依赖,符合原设计
+- 项目路径:`门店检测工作台/store-ops/`
+- 服务默认 `127.0.0.1:8787`
+
+## 安装验收结果(全部 PASS)
+- `data/app.db` 完好:1409 家门店、1 个导入批次、2026-10 周期
+- KPI 与迁移说明自检值一致:**1409 / 469 / 323 / 929 / 276**
+- 用户自定义阈值保留:`hx_amt_base = 10000`(默认 12000),面板标「自定义」
+- 用户自定义文案保留:43 个模板字段,其中 3 个为自定义,概览行变量别名正常渲染(无残留花括号)
+- 详情抽屉:7 条指标条 / 17 个基准刻度 / 5 个问题项 / 预览按钮
+- 预览弹窗:按模板生成 698 字反馈文案,`badvars=0`
+- 文案配置面板:8 组、43 字段、43 个「+ 变量」按钮,实时预览正常
+- 指标配置面板:8 组、16 个输入框
+- 导入面板:导入历史正常
+- 导出 CSV:零核销筛选 276 家 + 表头,BOM 与中文列名正常
+- 收件箱 watcher(隔离副本验证):丢入 CSV → 20 秒内入库并归档到 `processed/`
+
+## 新增:macOS 启动器
+- `store-ops/启动工作台.command`(可执行)
+  - 自动探测 Python 3.7+,版本不够给安装指引
+  - 参数:`[端口] --local --no-open`
+  - 启动前检查端口占用,占用时提示已有实例
+  - 默认 `--open` 自动开浏览器
+- README「启动」与「目录结构」、迁移说明第二节补 macOS 用法
+- 注意:`开放局域网访问.bat` 是 Windows 专用;macOS 首次运行在系统弹窗里点「允许」即可
+
+## 修复:文件名日期解析把「年月」误读成「年月日」(严重,会写错月份)
+- 现象:导入 `门店数据_2026-10.xlsx`(无日期列、整月汇总)→ 周期被写成 **2026-01**,
+  且被当成 **日粒度**、日期 `2026-01-00`(非法日期)
+- 根因:旧正则 `(20\d{2})[-_.]?(\d{1,2})[-_.]?(\d{1,2})` 三段全可选分隔符,
+  `2026-10.xlsx` 被回溯匹配成 `2026` + `1` + `0`
+- 修复 `ingest.guess_period_date` 重写,按「完整日期 → 年月 → 仅月份」优先级,且逐级校验合法性:
+  - 日粒度只认分隔符齐全且年月日合法的写法:`2026-10-30` / `2026/10/30` / `2026.10.30` / `2026年10月30日` / `20261030` / `10.28`
+  - 年月:`2026-10` / `2026.10` / `2026年10月` / `202610` / `10月`
+  - 月/日非法(如 13 月、0 日)自动退回上一级,不再产生 `2026-01-00`
+  - 回归测试 16 个文件名用例全 PASS
+- 连带修复:导入回包的 `granularity` / `period` 改为**按实际入库行**统计
+  - 原来 `granularity` 只看传入的 `date` 参数,带「日期」列的 CSV 会误报成 `period`
+  - 跨月文件现在回包 `periods` 明细,`period` 取行数最多的月份(前端按它刷新视图)
+
+## 验证(隔离副本 `/tmp/storeops_test`,不动真实数据)
+- 月汇总 CSV / 月汇总 XLSX / 日数据 CSV(含重复行)/ 跨月 CSV / 重复导入幂等 —— 全部符合预期
+- 真实库 KPI 重启后无回归:469/323/929/276
+- 测试文件与进程已清理
+
+## 环境备注(与 Windows 时代的差异)
+- 原记录「Chrome headless 会卡死」「Edge headless 截图」都是 Windows 结论;本机用 ego 浏览器(Chromium)验证,正常
+- `启动工作台.bat` / `开放局域网访问.bat` 仅 Windows 可用,保留不删(团队里可能还有 Windows 机器)
+
+## 新增:单店分析报告(可打印 A4 / 可下载单文件 HTML)
+- 需求:用户选择优先做「单店分析报告导出」,使用场景桌面优先、手机随后
+- 入口:详情抽屉底部按钮行(`预览分析结果` 金色主按钮 + `导出报告` 白色次按钮),Esc 先关报告再关抽屉
+- 报告五段:01 关键指标(复用 metricsOf/barColor 的指标条,带基准/目标/三级违规线刻度)→
+  02 风险与限流因素(问题 + 建议动作表)→ 03 经营数据(8 张数据卡)→ 04 分析洞察 → 05 门店调整动作
+- 顶部操作:【打印 / 另存为 PDF】(`window.print()`,走系统打印)与【下载 HTML】(自包含单文件,样式内联)
+- 新文件 `frontend/report.css`:报告专属样式,**自带全部依赖**(不依赖 style.css),
+  这样下载出去的单文件 HTML 与工作台内看到的长相完全一致
+- 打印:`@page A4` + `body.rp-on` 作用域下隐藏工作台外壳,普通页面按 Cmd+P 不受影响;
+  `-webkit-print-color-adjust:exact` 保证金色/语义色打出来
+- 手机:`max-width:640px` 下指标单列、数据卡两列,375px 实测无横向溢出
+
+### 关键设计决定
+- **报告不新增后端接口**:检测规则在后端(rules.py),文案渲染在前端(fbVars/fmtTpl),
+  报告属于「渲染层」,所以放在前端生成,避免把变量替换逻辑再抄一份到后端
+- 为让报告与反馈文案共用同一套洞察条件,把 `renderFeedback` 里的洞察段抽成 `insightTexts(s, X, flags)`
+- 回归验证:抽函数前后对全部 1409 家门店的 `feedbackText()` 做稳定哈希比对,
+  汇总哈希 `e86ee9` 完全一致、逐店差异 0 —— 重构对外零变化
+
+### 踩坑
+- `.rphead` 是 `<header>`,被 style.css 的全局 `header{display:flex;justify-content:space-between}`
+  命中,标题被挤到右侧;显式 `display:block` 并关掉 `header::after` 的装饰线
+- 全局 `th{background:#fdf9ee}` 等表格样式会串到报告表头;report.css 里把表头样式写全,
+  否则「工作台内预览」和「下载的 HTML」会长得不一样
+- A4 分页:`h2` 加 `page-break-after:avoid` + `h2 + *{break-before:avoid}`,避免章节标题孤零零留在页尾
+- 空态文案曾重复成「本期无明显异常,本期无风险项。」—— 直接渲染 `fb_no_issue` 模板,不再拼后缀
+
+## 待办(沿原 README「后续可做」)
+- 自动下载:接抖音来客/服务商 API 定时拉数,替代手动导出
+- 处理闭环:异常门店的整改动作记录与回填
+- 权限分层:区域经理/督导只看自己片区
+- 报告批量导出:按城市/督导一次导出多店 PDF

+ 428 - 0
项目记录/开发日志_2026-11-01.md

@@ -0,0 +1,428 @@
+# 2026-11-01 诊断规则重做(v2)
+
+## 起点:用户反馈
+「比起前端 UI 样式,门店检测的逻辑更需要调整。现在的门店诊断结果并不严谨,
+诊断建议也不是根据门店实际问题给出的,而是一股脑全给。」
+要求:先做思维导图统一梳理「每项指标没达到的问题 + 整改建议」,确认后再写进代码。
+
+## 第一步:用数据定位不严谨在哪
+对 1409 家门店实测,找出 5 个硬伤:
+
+| 问题 | 实测 | 结论 |
+|---|---|---|
+| 核销金额基准 10000 当不达标线 | 全品牌中位 165 元,仅 8% 达标 | 命中 1023 家 = 噪音,不是诊断 |
+| 回复率 <50% | 命中 1305 家(93%) | 没有区分度 |
+| 评分 0 分 | 91 家被判「低于 4.3」 | 0 分是未开分,不是评分低 |
+| 差评率小样本 | 22 家「1-2 条评价」就判 | 一条差评 = 非常严重 |
+| 整改动作 | 9 条固定清单,每家全给 | 用户说的「一股脑全给」 |
+
+补充发现(重要,推翻了之前的判断):
+- 源头 CSV **没有一个空单元格**,所有缺失都是字面 0
+  → 不是「导入丢了空值」,而是**平台用 0 表示「未发生/未开分」,代码当成了真实值**
+- 「待核销 = 意向成交 − 核销」在同源数据里 868 家会算出负数(没扣退款)
+  → 文案里写的「真实未核销」是错的,已改成「估算未核销」
+
+## 第二步:思维导图(用户确认后才动代码)
+产出:
+- `项目记录/规则设计_v2_思维导图.html`(自包含,可打印)
+- `项目记录/规则设计_v2_明细.md`(同一份规格的表格版)
+- 生成脚本按规格统一生成,文档与代码同源
+
+结构:4 层漏斗(流量/转化/履约/口碑)× 16 条规则,5 档分级,1 棵诊断树,≤3 条动作。
+
+## 第三步:用户拍板的 4 个决定
+1. **核销金额保留为考核项,但提高门槛** → 只在「核销率已达标」时才判客单价,
+   问题性质从「金额不够」变成「客单价低」,命中 1023 → 287 家
+2. **差评率小样本仍然判,但封顶预警线** → 22 家从「非常严重」降为「预警线」,不再有 1 条差评判 R3
+3. **评分 0 分仍算不达标,文案改「未开分」** → KPI 总数 323 不变,但文案与动作分开
+4. **诊断树按漏斗顺序** → 违规 → 流量 → 转化 → 履约 → 口碑
+
+## 第四步:写进代码
+
+### backend/rules.py(重写)
+- `DEFAULT_THRESHOLDS` 新增 7 个可调门槛:visit_low / cvr_low / pend_high /
+  hx_min_deal / amt_min_coupons / cp_min_new / cp_min_total / reply_min_new
+- 16 条规则,每条含 档位/层/级别/模板键/变量
+- `diagnose()` 返回 problems / primary / secondary / actions / sev / tier / headline / hd_key
+- 根因去重用 if/elif 链保证互斥:零成交 → 成交未核销 → 核销率低 → 客单价低
+- `bad_rate()` 改为返回 (ratio, 口径, 是否小样本),小样本在 diagnose 里封顶 R1
+- 保留 `detect()` 兼容旧调用
+- 新增 `V1_TEXTS`:v1 默认文案快照,用于迁移
+
+### backend/db.py
+- `get_thresholds()` 用默认值补全库里没有的新键(老库零迁移)
+- `get_texts()` 迁移:存档值 == v1 默认值 → 用新默认;否则视为用户自定义保留
+  (实测保留了用户的 3 处自定义:概览行模板、访问行模板、传素材动作文案)
+- `aggregate_period()` 补输出 reviews_new/bad_new/reviews_total/bad_total/lit
+  —— 差评率判定需要原始计数,之前只算了一个 cp_rate 出来
+
+### backend/server.py
+- payload 增加 tier / headline / hd_key / primary / secondary / actions / problems
+- 保留 flags 字段(列表页、标签渲染继续可用)
+- 导出 CSV 增加「诊断结论 / 主因 / 全部问题 / 整改动作」四列,
+  整改动作只给诊断树的 ≤3 条
+
+### frontend/app.js
+- `diagnosisOf(s, X)`:用当前(可能未保存的)模板重新渲染问题与动作
+- `renderFeedback()` 重写:概览 → 结论 → 主因+次要 → 待核销 → 流失 → 洞察 → ≤3 动作
+- **删除 `previewFlags()`**(前端复刻的第二套判定规则),
+  改为直接用后端 problems + 模板键重渲染 → 规则唯一源真正落到 rules.py
+- 抽屉改为「结论横幅 + 主因 + 次要 + 指标条 + 折叠的其余提升点」
+- 报告同步;【指标配置】面板接入 7 个新阈值
+- 文案配置面板从 43 个字段扩到 65 个(新增 hd_* 结论句、新问题文案、新动作)
+
+## 验证
+- 规则命中数与原型脚本完全一致(LIT 867 / REPLY 822 / HX 695 / CP 349 / AMT 287 …)
+- 主因分布:违规 469、核销率低 417、客单价 185、访问不足 101、成交未核销 81 …(13 种主因)
+- 平均问题 3.15 条、平均动作 2.15 条(≤3 上限生效)
+- Node harness 全量 1409 家:残留变量 0、动作超 3 条 0、有问题无主因 0
+- 报告渲染全量 1409 家:0 报错
+- 导出接口 16 列、469 行正常
+
+## 已知遗留
+- 「待核销」仍是估算值(缺后台真实未核销列)。要根治需要数据源提供该列。
+- 回复率命中 822 家(58%)仍偏高,因为品牌确实 58% 的门店有新评价但没回复;
+  阈值 `reply_min_new` 可在面板调到 5,命中会降到 414 家。
+- 洞察段仍是 6 条全列,未与主因联动;如需可下一步收敛为「主因相关的 2-3 条」。
+
+---
+
+# 第二轮:按用户编辑的可视化规则图落地(v2.1)
+
+## 输入
+用户在 `规则全景图_可编辑.html` 里改了 11 条规则、新增 1 条、删除 2 条,
+导出 `规则修改_2026-11-01.json`。已用浏览器实时状态核对,导出的就是最终版。
+
+## 用户的改动
+| 类型 | 内容 |
+|---|---|
+| 删除 | `VISIT0`(并入 VISIT,判定改为「访问 < 100」含 0)、`SCORE0`(并入 SCORE) |
+| 新增 | `新增评价低`(新增评价 < 10 条,T 级) |
+| 阈值 | 视频 0 → <10、点亮 0 → <10、转化率 2% → 10% |
+| 重命名 | 客单价偏低 → 核销金额偏低;退单率超线 → 商责退单率超线 |
+| 重写判定 | 核销金额去掉「核销率已达标」前置条件 |
+| 文案 | 12 条问题的动作全部改写为更具体的门店执行动作 |
+
+## 两个需要我定的点(用户确认)
+1. **核销金额独立判定** —— 只要金额 < 10000 就报,不管核销率(命中 1299 家 / 92%)
+2. **问题全列、每条给动作** —— 不再截断到 3 条
+
+## 代码改动
+- `rules.py`
+  - 规则表 15 条;`DIAGNOSIS_ORDER` 去掉 VISIT0/SCORE0,加入 RVOL
+  - 核销链改为互斥 if/elif,`AMT` 移出链外独立判定
+  - `SCORE` 一条规则内区分 0 分(走 `t_SCORE0`「未开分」文案)
+  - 动作不再截断;新增 `_clean_action()` 去掉动作行首的 ✅,避免与「✅ {动作}」模板叠加
+  - 新增阈值 `video_low` / `lit_low` / `new_review_low`,`cvr_low` 默认 2 → 10
+  - `hd_key` 回退链:规则 key → 模板 key(区分未开分)→ 层 → OK
+- `db.py`
+  - `set_texts()` 改为**只存与默认值不同的键** —— 以后改默认文案不会再被旧存档盖住,也不再需要 V1 快照比对(保留 V1 兼容历史存档)
+- 前端
+  - `diagnosisOf` 的动作覆盖全部问题;风险段、抽屉、报告全部改为「问题全列」
+  - 阈值面板接入 3 个新参数;文案面板移除 VISIT0、加入 RVOL
+
+## 踩坑
+- 用 `str.replace` 批量改文案时**误伤了 `V1_TEXTS` 快照**(`t_VISIT` / `t_VIDEO` 两处字符串完全相同),
+  导致迁移判断失效、旧值被当成用户自定义。已按 V1 真实值修复,并改为「只存差异」从根上避免这类问题。
+- 用户把转化率判定写成 10%,但参数 `cvr_low` 还是 2% —— 条件文本和参数是两处,已按 10% 统一。
+
+## 当前命中(2026-10,1409 家)
+| 规则 | 命中 | 规则 | 命中 |
+|---|---|---|---|
+| 点亮偏低 | 1305 (93%) | 评分不达标 | 323 (23%) |
+| 核销金额偏低 | 1299 (92%) | 未核销积压 | 253 (18%) |
+| 转化率低 | 1213 (86%) | 成交未核销 | 221 (16%) |
+| 新增评价少 | 1170 (83%) | 商责退单率 | 165 (12%) |
+| 回复率偏低 | 822 (58%) | 访问过低 | 147 (10%) |
+| 核销率不达标 | 695 (49%) | 零成交 | 56 (4%) |
+| 视频量少 | 437 (31%) | 投诉率 | 11 (1%) |
+| 差评率超线 | 349 (25%) | | |
+
+平均每家 6.01 条问题 / 6.01 条动作,单店最多 11 条。
+
+## 待观察
+- 主因有 57%(798 家)落在「转化率低」,因为 CVR 阈值放到 10% 后命中 86%,且诊断树排第 4 位。
+  若觉得结论太单一,可把 CVR 在诊断树里往后挪,或把阈值收回到 5% 左右。
+- 洞察段 6 条与问题列表内容重复度高,可考虑收敛。
+
+---
+
+# 第三轮:近30天滚动窗口(快照模式)
+
+## 需求
+用户要求:每天从抖音来客「报表集市」导出近30天数据并入库,分析口径围绕近30天滚动窗口。
+
+## 已发现的问题(动手前先验证)
+把「按统计周期」的导出文件直接丢进旧版导入,会**静默写错**:
+统计日期是 `2026-10-03-2026-11-01` 这种区间字符串,旧代码只截取开头 `2026-10-03`,
+结果 **30 天的汇总被当成「10 月 3 日一天」的日报**,窗口结束日丢失。
+(测试时误把 2 家测试门店写进真实库,已清理,KPI 复核无异常。)
+
+## 已完成:快照数据模型
+- `metrics` 新增 `window_start` / `window_end`,`granularity` 增加 `snapshot`
+- 老库自动迁移(PRAGMA 检查 + ALTER TABLE),无需手工操作
+- `ingest.parse_range()` 识别 `2026-10-03-2026-11-01` / `~` / `至` / `/` 分隔等写法
+- 区间 → `period` 记「截至日」、每店一行、`granularity='snapshot'`
+  - 首尾同一天 → 自动退化为单日日报,不会误判
+- `get_periods()` 返回快照标签「近30天 · 截至 11-01」并带窗口区间
+- `aggregate_period()` 快照的「数据天数」按窗口长度算
+- 前端周期下拉显示快照标签,顶部数据说明显示统计区间
+- 月汇总数据与快照共存,互不影响
+
+验证:隔离副本连续导入 11-01 / 11-02 两份快照,周期列表、聚合、窗口字段全部正确。
+
+## 进行中:生意经导出自动化
+已确认的事实:
+- ego lite **已登录**生意经(河南怡呵美水光企业管理有限公司)
+- 平台**会记住上次勾选的维度和指标**,进门店 tab 后自动勾选 47 项,无需每天重选
+- 建任务有每日次数限制(当时显示「今日剩余 29 次」)
+- 下载列表是 Bytedance 表格,每行有「下载」按钮
+
+卡点:日期区间控件(byted-date-picker)点选不生效。
+试过合成 click、真实 click、标记后 click,日格能拿到 `byted-date-selected` 样式但输入框值不变;
+面板里存在多个 popper,疑点是我抓到的不是输入框对应的那一个。下一步需要重新定位 popper 与输入框的绑定关系。
+
+尚未创建任何下载任务,未消耗配额。
+
+## 生意经接口已打通(关键进展)
+
+放弃点 UI,改为抓接口。找到的统一网关:
+
+```
+POST https://www.life-data.cn/api/lowcode_api/query
+     ?projectKey=shengyijing&pageId=15352&interface_id=0ac0bacd
+```
+
+必带的请求头(少了会返回 code -1):
+```
+content-type: application/json; charset=utf-8
+root-life-account-id: 7376197379193669659
+life-account-id:      7376197379193669659
+x-tt-trace-log: 01
+```
+
+请求体(创建「按日 × 日期区间 × 全部门店」的导出任务):
+```json
+{"interface_id":"0ac0bacd","data_query":{
+  "data_query_type":"data_query",
+  "indicators":[ 41 个字段,poi_id / enter_poi_cnt / verify_cert_cnt_1d / reply_rate_ratio ... ],
+  "filters":{"operator":"and","filters":[
+    {"field":{"name":"date"},"filter_operator":"between","range_values":["20261003","20261101"]},
+    {"field":{"name":"poi_selector_json"},"filter_operator":"equal","value":"{全部门店选择器}"},
+    {"field":{"name":"ignore_zero_rows"},"filter_operator":"equal","value":1}]},
+  "group_by":["day"],
+  "order_by":[{"order_field":"day","order_type":"desc"}],
+  "extra":{}}}
+```
+
+返回:`{"code":0,"status":"submit_success","task_id":"431b88fb-..."}`
+
+**意义**:日期区间完全由我们控制,彻底绕过了点不动的日历控件。
+
+已捕获的其他接口:
+- `interface_id=d715cc19` —— 按 `query_task_name` 查任务(可用于轮询状态)
+- `interface_id=9e029004` —— 查 `total` / `available`(当日剩余建任务次数)
+- `interface_id=0ac0bacd` + `data_query_type=meta_query` —— 拉字段元数据(64 个字段)
+
+## 待办
+1. 轮询任务状态 → 拿下载地址(或找到能直接返回数据行的查询接口,连文件都不用下)
+2. 把 JSON/文件转成 ingest 能吃的格式,落到 `data/inbox/`
+3. 每日定时(launchd 或 server 内置线程)
+
+## 清理
+测试期间创建过 2 个任务(「同步测试_1101」「报表集市_门店」),均已撤销,
+下载列表恢复为原来的 2 条已完成任务。
+
+## 同步链路跑通(端到端验证通过)
+
+### 关键结论
+1. **导出文件是 .xlsx**(不是 zip),现有导入器直接支持
+2. 下载地址由任务接口返回的 `download_link` 给出,是**签名 URL**,有效期约 1 天
+3. Python 直连被 CDN 挡(403 空响应,缺 secsdk 令牌),**走 ego 页面上下文调接口可行**
+4. 从建任务到出下载地址约 **3.5 分钟**,脚本里轮询上限 10 分钟
+
+### 完整链路(已实测)
+```
+sync/run_sync.sh
+  → ego 打开生意经(复用登录态)
+  → POST interface_id=0ac0bacd 建任务(group_by=[],日期=今天-29~今天)
+  → 轮询 interface_id=d715cc19 拿 download_link
+  → page.fetch 下载 xlsx 到 data/inbox/门店数据_<start>_<end>.xlsx
+  → 服务端 watcher 20 秒内导入
+  → 文件名里的日期区间被识别 → 按快照入库 → 周期下拉出现「近30天 · 截至 11-01」
+```
+
+### 新增文件
+- `sync/life_sync.mjs` —— 同步主脚本(建任务/轮询/下载)
+- `sync/run_sync.sh` —— 定时调用入口(带日志与失败标记)
+- `sync/com.yhm.lifesync.plist` —— launchd 每日定时模板
+- `ingest.range_in_filename()` —— 文件名带日期区间时自动按快照入库
+- `ingest` 支持显式 `window=(start,end)` 参数
+
+### 踩坑:一次真实的误写与修复
+第一次同步时**服务还在跑旧代码**,没加载窗口识别,结果:
+- 30 天数据被当成「2026-10 月度汇总」,覆盖了原有 1409 家月度数据
+修复:备份库 → 清掉污染行 → 重导原始 `门店数据_10月.csv` 恢复月度 → 再把 xlsx 按快照重导
+修复后:`2026-10` 月度 1409 家 + `2026-11-01` 快照 1413 家,两者共存
+**教训:改完 ingest 必须重启服务再触发收件箱导入。**
+
+### 当前状态
+- 快照周期「近30天 · 截至 11-01」1413 家,已是工作台默认周期
+- KPI(该窗口):469→467 / 323→327 / 929→926 / 276→266
+- 今日建任务配额剩余 26 次(同步 2 次 = 测试)
+
+## 定时任务:launchd 走不通,改成服务内置调度
+
+### 先试了 launchd(失败)
+配好 `~/Library/LaunchAgents/com.yhm.lifesync.plist`(每天 01:00),`launchctl load` 成功、日历项注册成功,
+但触发后报 `/bin/bash: …/run_sync.sh: Operation not permitted`。
+
+逐步排查:
+1. 空环境 `env -i` 直接跑脚本 → **正常**,不是 PATH 问题
+2. `launchctl submit` 探针 `cd` + `ls` → **成功**,不是目录不可达
+3. 探针改用 `head 脚本路径` → **也被拒**,`head=1`
+
+结论:**macOS TCC 保护 `~/Documents`,系统级 launchd 任务读不到这个目录里的文件内容**。
+要绕过必须在「系统设置 → 隐私与安全性 → 完全磁盘访问」里手动把 `/bin/bash` 加进去。
+已卸载并移除该 LaunchAgent,plist 留在 `sync/` 下作参考。
+
+### 改成服务内置调度(可用)
+工作台服务本身是从终端启动的,具备该目录的读权限,所以把定时器放进服务里:
+
+- `server.daily_sync_loop()` 线程:算到下一个 01:00,睡到点再跑
+- **启动补跑**:距上次成功超过 20 小时 → 启动 90 秒后自动补一次(解决关机/休眠错过)
+- `POST /api/sync` 手动触发,`GET /api/sync/status` 查状态
+- 左侧栏新增【同步数据】按钮,点完每 5 秒轮询状态,完成后自动刷新数据
+- `last_sync` 时间戳存 settings 表
+
+### 实测
+- 启动补跑:21:11:01 触发 → 21:15:33 成功,下载 298KB,入库 1413 家
+- 批次记录的周期已修正为 `2026-11-01`(之前误记成 `2026-11`)
+- 启动器加 `-u`,服务窗口能实时看到同步进度
+
+### 当前配置
+- 每天 01:00 自动同步
+- 快照周期「近30天 · 截至 11-01」1413 家,为工作台默认周期
+- 月度 2026-10 数据 1409 家保留
+
+## 阈值改面板 = 直接改代码(v2.2)
+
+### 需求
+用户:「我想要的是在【指标配置】面板改数字,就会直接同步把规则逻辑一起修改」
+—— 不要「代码默认值」和「库里自定义值」两套数字打架。
+
+### 实现
+- 新增 `backend/codesync.py`
+  - `write_defaults(T)`:把新阈值写回 `rules.py` 的 `DEFAULT_THRESHOLDS`
+    只替换已有键的「值」,保留键名/缩进/行尾注释/其它代码;原子写入 + 生成 `.bak`
+  - 写完后清字节码缓存并 `importlib.reload(rules)`,立即生效
+  - `get_factory()`:首次运行时把出厂值快照到 `data/factory_thresholds.json`
+- `PUT /api/thresholds` → 写库 + 写回代码
+- `POST /api/thresholds/reset` → 回到出厂值 + 同样写回代码
+- 面板语义改为对照**出厂值**:显示「出厂值 / 已自定义」
+- 保存成功提示区分「已生效,并同步写回代码默认值」/ 写回失败原因
+
+### 规则地图跟随
+`gen_editable_map.py` 里所有判定条件中的数字改为从 `rules.DEFAULT_THRESHOLDS` 读取,
+改阈值后重新生成,图上文字自动同步(不再写死「≥30 张」)。
+
+### 踩了两个坑
+1. **正则分组用错**,把旧值和新值拼在一起,`"score_base": 4.34.3`、`"pend_high": 3030`,
+   直接把 `rules.py` 写坏。靠事前的 `.bak` 备份恢复。
+   教训:改源码的函数必须先在隔离副本里跑一遍,并且**必须校验语法**(ast.parse)再落盘。
+2. **macOS 自带 Python 把 .pyc 放在 `~/Library/Caches/com.apple.python`**,
+   不在项目里的 `__pycache__`。删 `__pycache__` 无效,同尺寸同秒的改动会被旧字节码顶掉
+   (现象:源码是 10,导入却是 15)。已在 `write_defaults` 里主动清理该缓存路径。
+
+### 验证
+连续 PUT 12 → 10 → 8 → 10,每次都用一个新进程从磁盘重新导入核对:
+代码默认值、服务内默认值、源码三者完全一致。
+
+## UI:数据瘦身 + 放大字号 + 滚动加载
+
+### 起因
+用户反馈「排版太紧凑,字体也小」,同时问「滚动加载会不会每次刷新渲染太多」。
+
+量了一下发现真正的问题不是渲染,是**每次刷新要传 6.4 MB**:
+同一份问题数据被下发了 4 遍(problems / flags / primary / secondary / actions)。
+
+### 1. 数据瘦身:6.4 MB → 1.9 MB(降 70%)
+- 后端只下发「模板键 + 变量」:`{k,t,l,v,c,p,a,x}`,文案由前端按当前模板渲染
+- 删掉 `flags` / `primary` / `secondary` / `actions` / `cats`(都可从前端派生)
+- 前端新增 `decorateStore()`:加载后一次性还原成原来的结构,其余代码零改动
+- 导出接口 `/api/export.csv` 改为按模板现场渲染(内容与原来一致)
+- KPI、分布统计、分类筛选改用 `tier` / `problems` 派生
+
+### 2. 放大字号与间距
+- 基础字号 13.5 → 15px;12.5/11.5/10.5 档整体上调一档(现在 14/13/12)
+- 表格单元格内边距 11px → 14px,表头 10px → 12px
+- 卡片内边距 18/20 → 22/24;图表高度 222 → 290
+- 收窄的 `.wrap` 最大宽 1320 → 1400,两侧留白加大
+
+### 3. 首屏 20 条 + 滚到底加载
+- `FIRST_ROWS=20`、`STEP_ROWS=20`、`MAX_ROWS=300`
+- 去掉上一页/下一页,改成状态行「已显示 20 / 1413 家,继续下滑加载更多」
+- 滚到距底部 600px 自动追加;到 300 条上限后提示用筛选缩小范围
+- 冒烟测试:首屏 20 行、加载一次 40 行、300 行渲染 1ms / 157KB HTML
+
+### 未做
+- 密度切换开关(先按「舒适」档定死,用户看效果后再说)
+
+### 后续:改回分页
+用户反馈滚动加载不合适,改回分页:
+- `PAGE_SIZE = 10`,恢复上一页/下一页,状态行「第 1 / 142 页」
+- 移除 `loadMore` / `nearBottom` / 滚动监听 / 300 条 DOM 上限
+- 数据瘦身和字号放大保留
+验证:第 1 页 10 行、第 2 页 10 行、末页(142)3 行,按钮禁用状态正确。
+
+### 排序控件拆成两个下拉
+- 原因:原来 5 个选项把「字段」和「方向」写死在一起(如「按核销金额(低→高)」,方向不可切换)
+- 现在:`#fsort` 只选字段(严重度/核销金额/评分/核销率/访问量),`#forder` 选方向(倒序/正序)
+- 默认:严重度 + 倒序(与原来一致)
+- 空值(未开分、无核销率)无论正序倒序都排最后,不参与翻转
+- 同值时按核销金额兜底排序
+- 验证:5 字段 × 2 方向共 10 种组合,排序结果均正确
+
+### 列表增加列(7 列 → 13 列)
+原来只有 评分/核销率/核销金额/退单率,漏了 R 级违规里的**投诉率、差评率**,也看不到流量和履约。
+
+新增 6 列:**访问 / 客资(意向成交券)/ 待核销 / 投诉率 / 差评率 / 回复率**
+
+配套调整:
+- 超标的值直接标红(R 级红 `#b5563a`,未达标金 `#c8952c`),扫一眼就能定位
+- 评分 0 分显示「未开分」而不是 0.0;差评率无评价显示「无评价」,不参与排序翻转
+- 「触发问题」列原来显示完整问题文案(含数字),与新增列重复且把行高撑到 127px,
+  改为显示**问题分类短名**(差评率超线 / 评分不达标 / 成交未核销…),最多 5 个 + 「+N」
+- 列宽收紧:单元格内边距 14/12 → 13/10,标签字号 12 → 11.5
+- `.tablewrap{overflow-x:auto}` 兜底,当前 1274px < 容器 1292px,不出现横向滚动条
+- 行高 127px → 93px
+
+## 口径变更:核销率分母改用「门店页成交券数」
+
+### 需求
+用户:「门店页成交券数,现在统一改成这个。包括核销率」
+—— 原来看板上的「客资」列其实是**门店意向成交券数**(intend_deal),用户要的是**门店页成交券数**(deal)。
+
+### 改动范围(全链路统一)
+| 位置 | 原来 | 现在 |
+|---|---|---|
+| 核销率 | 核销券 ÷ 意向成交券 | 核销券 ÷ **门店页成交券数** |
+| 待核销 | 意向成交券 − 核销券 | **成交券数** − 核销券 |
+| 零成交判定 | 意向成交券 = 0 | **成交券数** = 0 |
+| 成交未核销 | 意向成交券 > 0 且 核销 = 0 | **成交券数** > 0 且 核销 = 0 |
+| 核销率样本门槛 | 意向成交券 ≥ 5 | **成交券数** ≥ 5 |
+| 转化率分子 | 成交券数 | 不变(本来就是这个) |
+| 返单/投诉率加权 | 按意向成交券加权 | 按成交券数加权 |
+| 文案变量 {客资} {成交券数} | 意向成交券数 | **门店页成交券数** |
+| 表格「客资」列 | 意向成交券数 | 改名「成交券」,取成交券数 |
+| 报告数据卡 | 门店意向成交券数 | 门店页成交券数 |
+
+### 影响(2026-11-01 数据)
+- 核销率 < 40% 的门店:926 → **910 家**(分母变小,核销率整体略升)
+- 中位核销率:27.8% → 28.1%
+- **核销率 > 100% 的门店:4 → 15 家**(成交券数可能小于核销券数,因为核销的是更早周期的券)—— 属正常现象,不做截断
+- 其他 KPI 不变:R 467 / 评分 327 / 零核销 266
+
+### 备注
+- 数据库里两个字段都保留(deal 与 intend_deal),只是**判定与展示统一用 deal**
+- 文案模板里用户写的 `{门店意向成交券数}` 通过别名仍然有效,但取的是成交券数的值

+ 147 - 0
项目记录/开发日志_2026-11-02.md

@@ -0,0 +1,147 @@
+# 2026-11-02 规则调整(三条)
+
+## 1. 四层改为并行,互不压制
+原来「零成交(转化层)」会压制「成交未核销 / 核销率低(履约层)」—— 这是把四层当成漏斗了。
+现在拆开:转化层、履约层各自独立判定;只有**履约层内部**保留根因去重
+(成交未核销 → 核销率低,同一根因不报两条)。
+文档和规则地图里的「互斥」表述同步改为「履约层内命中则不再判…」。
+
+## 2. 隐藏「点亮后的投稿数」
+`lit_posts` 实测只有 14 / 1413 家非零(合计 42 次),数据不可信。
+它本来就没有在任何界面展示过;这次更进一步,**同步时不再向生意经请求该指标**
+(`visible_checkin_item_cnt_1d`),少一列无用数据。
+
+## 3. 上榜信息作为检测指标
+数据实况:980 家「-」(未上榜)、433 家有内容(「入围XX榜」或「XX榜第N名」)。
+
+新增两条 T 级规则(流量层):
+| ID | 判定 | 问题文案 |
+|---|---|---|
+| RANK0 | 上榜信息为空或「-」 | 未上榜(好评榜/人气榜均未进入) |
+| RANK_IN | 有内容但没有「第 N 名」 | 仅入围未进榜:{榜} |
+
+- `db.aggregate_period` 新增 `rank_note`(取最近一次有内容的)
+- 接口 payload 增加 `rank`
+- 表格新增「上榜」列:显示最好名次(绿色「第2名」)/「入围」/「—」
+- 抽屉里显示完整榜单内容(金色左边框的提示块)
+- 文案配置面板新增 4 个模板字段
+
+### 当前命中(2026-11-02 周期,1413 家)
+未上榜 980(69%)、仅入围 155(11%)、有点名次 278 家。
+规则总数 15 → **17 条**,平均每家 7.24 个问题。
+
+### 表格
+新增「上榜」列后 14 列;收紧内边距与列宽后 1148px < 容器 1155px,无横向滚动条。
+
+## 同步数据改为「弹窗确认」
+
+### 原因
+`同步数据` 一点就执行 —— 但这个操作会消耗平台配额、跑 3~9 分钟、还会弹出浏览器窗口,误点代价不小。
+
+### 实现
+点按钮先查 `/api/sync/status`:
+- **有任务在跑** → 不发新请求,提示「同步正在进行中(21:23 开始),完成后会自动刷新」
+- **空闲** → 弹出确认框
+
+确认框内容:
+| 项 | 值 |
+|---|---|
+| 统计窗口 | 2026-10-04 ~ 2026-11-02(前端按今天往前 29 天算) |
+| 数据来源 | 抖音来客 · 生意经报表集市 |
+| 预计耗时 | 3 ~ 8 分钟(平台排队时间不定) |
+| 会消耗 | 1 次建任务配额(每天约 30 次) |
+| 上次同步 | 2026-11-02 01:04 |
+
+底部提示:同步期间会弹浏览器窗口、不影响继续使用工作台、每天 01:00 也会自动同步。
+按钮:取消 / 开始同步;支持 Esc、点遮罩、× 关闭。
+
+### 实测
+- 直接调用 `showSyncDialog()` 渲染正常,字段与按钮齐全
+- 点击按钮时因当时正有同步在跑,正确走了「进行中」提示分支(没有重复发起)
+- 该次手动同步最终 21:23:10 → 21:32:06 成功,下载 294KB 并自动入库
+
+### 备注
+同步期间如果同时用 ego 浏览器做别的自动化,会和同步脚本抢浏览器,导致变慢。
+这次手动同步用了 9 分钟(平时 3~5 分钟),期间我正好在用 ego 核对界面。
+
+## 导入历史增加「导出」按钮
+
+### 需求
+「每一条历史导入数据后面加一个导出按钮,可以用于导出历史报表」
+
+### 实现
+- 每行历史记录右侧加【导出】按钮,点击下载该批次的检测清单
+  `GET /api/export.csv?period=<批次周期>`
+- 文件名自带周期:`门店检测清单_2026-11-02.csv`
+- 若该批次的周期在当前数据库里已不存在(早期批次记录的是旧值 `2026-11`),按钮置灰,
+  悬浮提示「该批次的数据已被后续导入覆盖,无法导出」
+- 历史行的文件名改为单行省略号截断,右侧固定显示周期 + 时间 + 按钮
+
+### 顺带修复
+导出的 CSV 里有 153 个单元格带换行(来自动作文案里的 `\n`,如「1. 检查…⏎2.点亮…」),
+Excel 打开会出现多行单元格。现在导出时把 `\r\n` 统一压成空格。
+
+### 验证
+- 点击第一个可用按钮 → 下载 `门店检测清单_2026-11-02.csv`,1414 行 × 16 列,含诊断结论/主因/全部问题/整改动作
+- 2026-11 的两条历史记录按钮正确置灰
+- 导出后 CSV 里不再有换行单元格
+
+## 导出改为「纯门店数据」
+
+### 需求
+「导出就导出实际门店参考就好,不用计算问题诊断、原因和整改动作这些。这些内容在单家门店查询时展示即可」
+
+### 改动
+导出(左侧栏【导出数据】+ 导入历史每行的【导出】)不再包含诊断内容。
+
+原来 16 列里有 4 列是诊断:`诊断结论 / 主因 / 全部问题 / 整改动作` —— 全部移除。
+
+现在是 17 列纯指标:
+
+```
+门店ID, 门店名称, 城市, 评分, 访问次数, 成交券数, 核销券数, 核销率, 核销金额,
+待核销券(估算), 商责退单率, 投诉率, 差评率, 评价回复率, 发布视频数, 门店点亮数, 上榜信息及排名
+```
+
+顺带调整:
+- 文件名 `门店检测清单_<周期>.csv` → `门店数据_<周期>.csv`
+- 左侧栏按钮 `导出清单` → `导出数据`
+- 导出接口内部不再调用 `rules.fmt_tpl` 渲染文案,只输出数字
+
+### 保持不变
+筛选条件(KPI 卡 / 城市 / 问题类型 / 评分区间 / 搜索)仍然生效——
+问题类型筛选需要先判定,所以内部还是会跑诊断,只是**结果不进导出文件**。
+
+### 验证
+2026-11-01 导出:1414 行 × 17 列,列名与内容均无诊断字段;
+左侧栏按钮点击下载 `门店数据_2026-11-02.csv` 正常。
+
+## 导出改为「原样返回导入的原始文件」
+
+### 问题
+用户指出导出的仍是加工数据。核对后发现:
+1. 生意经原始文件有 **40 列**,我们库里只存了 23 列
+2. 导出里混了 3 个计算列:核销率(核销券÷成交券)、待核销券(成交券−核销券)、差评率(中差评÷评价数)
+3. 其中 **经营风险差评率是平台直接给的**,我们却自己算了一遍,数字可能对不上
+
+### 方案(用户选择)
+**导出 = 原样返回当时导入的文件**,不含任何计算列。
+
+### 实现
+- `batches` 表新增 `file_path`,记录原始文件在 `data/processed/` 下的文件名
+- 收件箱导入:先算归档名 → 导入时写入批次 → 再移动文件
+- 上传接口 `/api/import`:同样把原件保存一份到 `data/processed/`
+- 老批次自动回填:用 `created_at` 的时间戳匹配 `processed/` 里的文件名前缀
+  (如 `2026-11-02T21:32:14` ↔ `20261102_213214_门店数据_....xlsx`)
+- 新接口:
+  - `GET /api/batch/<id>/file` —— 指定批次的原始文件
+  - `GET /api/export.file?period=` —— 该周期最近一次的原始文件
+- 下载时文件名去掉时间戳前缀,恢复成 `门店数据_2026-10-04_2026-11-02.xlsx`
+- 前端:左侧栏按钮改为【导出原始数据】,导入历史每行按钮改为【原始文件】;
+  没有留存原件的批次(早期 Windows CSV)按钮置灰
+- 移除不再使用的 `exportCsv()`;`/api/export.csv` 保留为「筛选结果 CSV」接口
+
+### 验证
+- `/api/batch/11/file` 下载得到 40 列 × 1428 行,与原始 xlsx 完全一致
+- 左侧栏点击 → `门店数据_2026-10-04_2026-11-02.xlsx`
+- 历史列表 8 条:5 条可下载,3 条(2026-10 的 CSV、一条重导入)置灰

+ 209 - 0
项目记录/开发日志_2026-11-03.md

@@ -0,0 +1,209 @@
+# 2026-11-03 账号管理与权限配置
+
+## 需求
+「增加账号管理、权限配置的功能」—— 之前系统完全没有身份认证,
+同一局域网任何人打开网址都能看全部数据、还能改阈值。
+
+## 设计:三级角色
+
+| 角色 | 查看 | 导出 | 导入 | 同步 | 改阈值/文案 | 账号管理 |
+|---|---|---|---|---|---|---|
+| 管理员 admin | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| 运营 editor | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
+| 查看 viewer | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
+
+## 实现
+
+### 新增 `backend/auth.py`
+- `hash_password()`:PBKDF2-HMAC-SHA256,12 万次迭代,每账号独立 16 字节盐
+- `verify_password()`:`hmac.compare_digest` 防时序攻击
+- `check_strength()`:至少 6 位、不能全是数字
+- 角色与权限表(`ROLES` / `PERMS` / `GRANTS` / `can()`)
+- 会话令牌:`secrets.token_urlsafe(32)`,**库里只存 SHA-256 哈希**
+
+### `db.py` 新增两张表
+- `users`:用户名、密码哈希、盐、角色、启用状态、是否需改密、最近登录
+- `sessions`:令牌哈希、用户、过期时间、IP;过期自动清理
+
+### `server.py`
+- 每个请求解析 `sid` Cookie → 还原当前用户
+- 路由权限表 `PERM_RULES`,未列出的 `/api/*` 默认只要登录
+- 未登录 → 401 `need_login`;权限不足 → 403 并说明当前角色
+- 新接口:`/api/login`、`/api/logout`、`/api/me`(GET/PUT)、`/api/users`(GET/POST/PUT/DELETE)、`/api/auth/state`
+- 未登录访问 `/` 直接给 `login.html`
+- 首次启动 `ensure_admin()` 创建 `admin / admin123`,标记 `must_change=1`
+- 保护规则:不能删除/停用自己;不能把最后一个管理员降级或停用;改密码会踢掉该账号已有会话
+
+### 前端
+- 新增 `login.html`(首次使用会显示初始账号提示)
+- 顶部显示「当前账号 · 角色」+ 改密码 / 退出
+- 按权限隐藏左侧栏入口(指标配置、文案配置、账号管理、导入、同步、导出)
+- 登录后若 `must_change` 弹出**不可关闭**的改密框
+- 新增账号管理侧面板:新建、改角色、停用/启用、重置密码、删除、最近登录时间
+
+## 验证
+- 未登录:`/` → 登录页;`/api/dashboard` → 401
+- admin 登录 → `must_change` 改密框弹出,全部 6 个入口可见
+- viewer:看板 200,导出/改阈值/账号管理/同步/导入 全部 403
+- editor:看板 + 导出 + 同步 200,改阈值/账号管理 403
+- 改密:旧密码校验通过后生效,原密码随即失效;弱密码被拒("密码至少 6 位")
+- 账号面板:3 行正确渲染,角色下拉 3 个选项
+
+## 踩坑
+1. `import auth` 没加进 server.py,登录报 `name 'auth' is not defined`
+2. `/api/users` 把 `GRANTS` 里的 `set` 直接序列化,`_json` 的 `default=str` 把集合转成了
+   `"['view']"` 这样的字符串,前端拿到的是字符数组 —— 改为 `sorted(v)`
+3. 权限测试时误触发了真实同步(`POST /api/sync` 返回 200 = 真跑了),
+   随后重启服务把同步打断,留下一个孤儿 ego 进程,已清理;数据未受影响
+
+## 导出入口合并
+
+### 问题
+用户问:左侧栏【导出原始数据】跟【导入数据】里的导出是不是重复了。
+
+### 结论
+有重叠但不完全等价:
+- 左侧栏那个 = 当前周期**最新一次**导入的原件
+- 导入历史每行的按钮 = **指定批次**的原件(同一周期导过多次时能区分)
+
+2026-11-02 就有两个批次(01:04 和 21:32),左侧栏只能拿到最新那份。
+
+### 处理
+合并入口,左侧栏去掉【导出原始数据】(6 项 → 5 项),导入面板里新增:
+- 【下载当前周期最新一份】按钮(高频操作仍然一键)
+- 一段说明 + 每条历史记录的【原始文件】按钮(低频、需要选批次时用)
+
+能力没有减少,只是入口收敛到「导入/导出是一对」的地方。
+
+### 验证
+- 左侧栏现在 5 项:文案配置 / 指标配置 / 同步数据 / 导入数据 / 账号管理
+- 导入面板里「导出原始文件」区块和按钮正常,历史 9 条记录按钮齐全
+- 权限映射同步改为 `btnExportCur`,查看角色看不到这个按钮
+
+## 新建账号改为一次性表单
+
+### 反馈
+「新建账号时,把所有要填的字段一次性列出,不要填完再跳下一个。角色用选择的,不要输入」
+
+原来用三次 `prompt()` 依次问:用户名 → 密码 → 角色(还得手打 admin/editor/viewer,很容易打错)。
+
+### 改动
+新增通用表单弹窗 `formModal({title, fields, validate, onSubmit})`,新建账号一次列出 5 个字段:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| 登录账号 | 文本 | 必填,限字母数字 `_ . -`,2~32 位 |
+| 显示名称 | 文本 | 选填,不填用账号名 |
+| 初始密码 | 密码 | 必填,≥6 位、不能全数字 |
+| 确认密码 | 密码 | 必填,两次必须一致 |
+| 角色 | **下拉选择** | 查看 / 运营 / 管理员,每项带一句说明 |
+
+其他细节:
+- 错误提示在表单内就地显示,不弹 alert
+- 回车即提交,Esc/点遮罩可关闭
+- 「重置密码」也一并改成同样的表单(原来也是 prompt)
+
+### 验证
+- 两次密码不一致 → 「两次输入的密码不一样」
+- 账号名含空格 → 「登录账号只能用字母、数字、_ . -,2~32 位」
+- 正常提交 → 弹窗关闭,列表立即出现「测试督导 test_dd」(已删除)
+
+## 修关闭按钮 + 右上角退出登录
+
+### 问题 1:账号管理面板的 × 点了没反应
+`closePanels()` 里只写了 `['thPanel', 'imPanel', 'txPanel']`,**漏了 `usPanel`**。
+现在补上,同时左侧栏按钮的高亮状态也一并复位。
+
+### 问题 2:退出登录不明显
+原来「改密码 / 退出」是标题下方的一行小链接,藏在 `hsub` 里。
+
+改成右上角一个账号区:
+
+```
+[ 近30天 · 截至 11-03(1416店)▾ ]  [ 临 临时验证/管理员  改密码  退出登录 ]
+```
+
+- 头像取名字首字,金色圆角块
+- 名字 + 角色两行
+- 「退出登录」做成描边按钮,hover 变红
+- 头部改为 `flex-wrap:nowrap` + `.hleft{flex:1}`,保证账号区始终在右上角
+  (窄屏 <1100px 时才折行)
+
+### 验证
+- 点 × → 面板关闭(`classList.contains('on')` false)
+- 左侧栏按钮二次点击也能收起
+- 右上角账号区 `rightTop=0, rightRight=0`,与标题同一行
+
+## admin 账号设为内置账号
+
+### 需求
+「admin 账号的删除按钮要去掉,admin 账号是必须有的」
+
+### 改动
+- 前端:`admin` 这一行不再显示【停用】【删除】,改为一个灰底说明「内置账号,不可停用/删除」
+- 后端:`_user_delete` / `_user_update` 直接拦截,返回
+  「admin 是内置账号,不能删除」/「不能停用」
+
+### 验证
+用另一个管理员账号调用接口:
+- `DELETE /api/users/1` → `admin 是内置账号,不能删除`
+- `PUT {"active":false}` → `admin 是内置账号,不能停用`
+账号列表不变,admin 仍启用。
+
+## 部署到服务器时账号与数据会怎样(实测)
+
+分别模拟了两种情况:
+
+### 情况 A:只拷代码,不带 `data/` 目录
+- 启动时自动创建内置账号 **admin / admin123**,并强制首次登录改密
+- **门店数据为空**:0 家门店、0 个周期,需要先同步或导入
+
+### 情况 B:整个目录拷过去(含 `data/app.db`)
+- 不会重新初始化,**沿用原有的所有账号和密码**
+  (实测:admin、lynch 都在;admin 的密码是改过的那个,`admin123` 已失效)
+- **全部历史数据都在**:1416 家门店、5651 条指标、4 个周期、9 个批次
+- 原始文件也一起带过去(`data/processed/`),导出功能可用
+
+### 结论
+一次迁移请连同 `data/` 一起拷;只要代码的话是干净的空白系统。
+`data/` 里包含:`app.db`(账号+指标)、`processed/`(原始文件)、`inbox/`、`factory_thresholds.json`。
+
+## 服务器干净部署(方案 A)交付
+
+### 用户选择
+「只拷代码,不带 data 目录」—— 服务器上是一个全新的空白系统。
+
+### 新增文件
+| 文件 | 用途 |
+|---|---|
+| `start.sh` | Linux/服务器启动脚本,非交互、支持 `PORT` 环境变量和 `--local` |
+| `deploy/store-ops.service` | systemd 服务模板(自动重启、日志进 journal) |
+| `DEPLOY.md` | 完整部署说明:环境要求、上传、启动、systemd、端口放行、备份、排错 |
+
+### 关键决策:包内文件名全部用 ASCII
+第一次打包时中文文件名在 zip 里是乱码(`unzip -l` 显示 `store-ops/�?��?�工�?�?�.bat`),
+和最早那个迁移包是同一个坑。服务器上乱码文件名会很难用,所以:
+- `门店检测工作台.service` → `store-ops.service`
+- `部署说明.md` → `DEPLOY.md`
+- 移除 `启动工作台.bat`、`开放局域网访问.bat`、`启动工作台.command`(服务器用不上)
+- 重打包后用 `zipfile` 校验:**中文文件名 0 个**
+
+### 服务器上没有 ego-browser 的处理
+自动同步依赖 ego-browser,服务器通常没有。原来会每天 01:00 报一次错。
+现在启动时先检测:
+
+```
+ 数据同步: 未启用(这台机器上没有 ego-browser)
+           请用【导入数据】上传从生意经导出的 Excel
+```
+
+并且不再启动 daily_sync_loop 线程。实测(模拟 HOME/PATH 里没有 ego)通过。
+
+### 验证(从 zip 全新解压跑一遍)
+- 解压后 21 个文件,文件名全部正常
+- 首次启动自动建 `admin / admin123`,强制改密
+- 登录成功,看板返回空数据(引导页正常)
+- 部署包 432KB,不含 data 目录
+
+### 已知限制(已写进 DEPLOY.md)
+服务器上没有自动同步,三种替代方案:手动导入 / 本机同步后上传 / 服务器装无头浏览器(不推荐)。

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 163 - 0
项目记录/规则全景图_可编辑.html


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 59 - 0
项目记录/规则设计_v2_思维导图.html


+ 79 - 0
项目记录/规则设计_v2_明细.md

@@ -0,0 +1,79 @@
+# 门店健康诊断规则 v2 · 明细(定稿,待写入代码)
+
+> 数据基准:2026-10 · 1409 家门店;命中数为实测
+> 档位:R 违规红线 / B 考核未达标 / T 运营提升 / N 样本不足
+
+## 判定原则
+
+| ID | 档位 | 指标 | 判定条件 | 当前命中 | 问题文案 | 整改动作 |
+|---|---|---|---|---|---|---|
+| — | — | 数据可信才判 | 样本不足只标「样本不足」,但差评率仍判、最高封顶到预警线 | — | — | — |
+| — | — | 根因去重 | 同一根因只报最上游一条:零成交 > 成交未核销 > 核销率低 > 客单价低 | — | — | — |
+| — | — | 五档分级 | R 违规红线 / B 考核未达标 / T 运营提升 / N 样本不足 / G 已并入考核 | — | — | — |
+| — | — | 动作要可执行 | 每条动作绑定该店真实数字,每家最多 3 条 | — | — | — |
+| — | — | 阈值可调 | 新增 5 个阈值全部进【指标配置】面板,不用改代码 | — | — | — |
+
+## ① 流量层 · 能不能被看到
+
+| ID | 档位 | 指标 | 判定条件 | 当前命中 | 问题文案 | 整改动作 |
+|---|---|---|---|---|---|---|
+| T-VISIT0 | T | 零访问 | 门店页访问次数 = 0 | 4 家 | 门店页本期零访问,平台没有分发任何流量 | 核查是否营业/被限流;确认团购在售;完成门店点亮 |
+| T-VISIT | T | 访问过低 | 0 < 访问次数 < 100(阈值可调) | 143 家 | 访问仅 {v} 次,曝光不足(品牌中位 433 次) | 点亮门店 + 更新 AIGC 素材;确认是否进入本地榜单 |
+| T-VIDEO | T | 零视频 | 新发布门店关联视频数 = 0 | 158 家 | 本期未发布任何门店视频,缺少内容入口 | 每周用后台 AIGC 工具产出 1 条图文或视频 |
+| T-LIT | T | 未点亮 | 门店点亮数 = 0 且 访问 > 0 | 867 家 | 门店未点亮,缺少到店客户的二次转化入口 | 店内放置引导物料,到店客户完成点亮并投稿 |
+
+## ② 转化层 · 看到了有没有下单
+
+| ID | 档位 | 指标 | 判定条件 | 当前命中 | 问题文案 | 整改动作 |
+|---|---|---|---|---|---|---|
+| B-DEAL0 | B | 零成交 | 访问 > 0 且 意向成交券数 = 0 | 56 家 | 有 {v} 次访问却零成交,转化环节断掉 | 检查团购是否在售、价格是否高于同城;更换主图与套餐结构 |
+| T-CVR | T | 转化率低 | 访问 ≥ 100 且 成交人数 ÷ 访问 < 2%(阈值可调) | 88 家 | 成交转化率仅 {c}%,低于品牌后 10% 分位(1.8%) | 优化团购主图与标题;上架低价引流品带动进店 |
+
+## ③ 履约层 · 下单了有没有核销
+
+| ID | 档位 | 指标 | 判定条件 | 当前命中 | 问题文案 | 整改动作 |
+|---|---|---|---|---|---|---|
+| B-HX0 | B | 成交未核销 | 意向成交券 > 0 且 核销券数 = 0 | 221 家 | 本期成交 {i} 张券,一张都没有核销 | 电话/私信触达全部未核销客户;核查门店核销流程是否卡单 |
+| B-HX | B | 核销率低 | 意向成交券 ≥ 5(阈值可调)且 核销率 < 40% | 695 家 | 核销率 {v}%,低于平台考核基准 40%(品牌中位 27%) | 对未核销客户做集中邀约;设置到店核销激励 |
+| B-AMT | B | 客单价偏低 | 核销率已达标(≥40%)但核销金额 < 10000,且核销券 ≥ 5 | 287 家 | 核销 {n} 张券只做出 {a} 元,客单价 {p} 元,核销率达标但金额上不去 | 调整套餐结构:上架中高价套餐、做加购搭配,不打价格战 |
+| T-PEND | T | 未核销积压 | 估算未核销券 ≥ 30 张(阈值可调) | 253 家 | 积压 {p} 张未核销券(估算,未扣退款,仅作邀约参考) | 7 天内完成一轮集中邀约,避免集中退单 |
+
+## ④ 口碑层 · 体验好不好
+
+| ID | 档位 | 指标 | 判定条件 | 当前命中 | 问题文案 | 整改动作 |
+|---|---|---|---|---|---|---|
+| R-TD | R | 商责退单率 | 超 1.27% / 3.18% / 6.35% 三级 | 165 家 | 商责退单率 {v}% 超{档} | 24 小时内处理在途退单;复盘重复扣款与服务不符;优先冲榜单缓解限流 |
+| R-TS | R | 投诉率 | 超 0.11% / 0.28% / 0.56% 三级 | 11 家 | 投诉率 {v}% 超线 | 24 小时内响应投诉工单;复盘服务环节并留档 |
+| R-CP | R | 差评率 | 新增 ≥10 用新增口径,否则累计口径;小样本最高只判到「预警线」 | 349 家 | 差评率({口径}口径){v}% 超线 | 逐条处理近 30 天差评;安排优质深度好评(避免无效评价) |
+| B-SCORE | B | 评分不达标 | 已开分(> 0)且 < 4.3 | 232 家 | 评分 {v},低于平台准入基准 4.3 | 回复差评 + 引导好评,目标先回 4.3 再冲 4.5 |
+| B-SCORE0 | B | 未开分 | 评分 = 0(仍计入「评分不达标」KPI,文案单独处理) | 91 家 | 门店未开分:{原因},暂无有效评分 | 优先引导带图深度好评,达到开分门槛 |
+| B-REPLY | B | 回复率低 | 新增评价 ≥ 1(阈值可调)且 评价回复率 < 50% | 822 家 | 有 {rn} 条新评价,回复率仅 {v}% | 安排专人每日回复,差评 24 小时内回 |
+
+## ⑤ 已确认的 4 项调整
+
+| ID | 档位 | 指标 | 判定条件 | 当前命中 | 问题文案 | 整改动作 |
+|---|---|---|---|---|---|---|
+| — | — | 核销金额保留为考核项,但提高门槛 | 只在「核销率已达标」时判,避免跟核销率重复;命中从 1023 家降到 287 家,问题性质变成「客单价低」 | — | — | — |
+| — | — | 差评率小样本仍然判,但封顶预警线 | 样本不足的 22 家全部降为预警线,不再出现「1 条差评 = 非常严重」 | — | — | — |
+| — | — | 评分 0 分仍算不达标,文案改「未开分」 | KPI 总数不变(仍为 323 家),但督导会看到「未开分」而不是「评分 0.0」 | — | — | — |
+| — | — | 诊断树按漏斗顺序 | 违规 → 流量 → 转化 → 履约 → 口碑,先修上游 | — | — | — |
+
+## 诊断树(漏斗顺序,取第一个命中作主因)
+
+| 顺序 | 命中条件 | 含义 | 处理方向 | 当前家数 |
+|---|---|---|---|---|
+| 1 | ① 有 R 级违规(退单 / 投诉 / 差评) | 平台已在处置或限流 | 先止血:处理违规项,再谈增长 | 469 |
+| 2 | ② 零访问 / 访问过低 | 没有流量进来,后面几步都无从谈起 | 先修曝光(点亮 + 素材 + 榜单) | 105 |
+| 3 | ③ 零成交 / 转化率低 | 有流量但没下单 | 修转化(品项、价格、主图) | 78 |
+| 4 | ④ 成交未核销 / 核销率低 / 客单价低 | 客户买了不到店,或到店不核销 | 修履约(集中邀约 + 核销激励) | 683 |
+| 5 | ⑤ 评分不达标 / 回复率低 | 口碑拖累自然流量与转化 | 修口碑(处理差评 + 专人回复) | 58 |
+| 6 | ⑥ 以上都不命中 | 本期达标 | 保持动作,关注冲刺目标 | 11 |
+
+## 输出结构
+
+| 字段 | 说明 |
+|---|---|
+| 一句话结论 | 这家店本期主要卡在「履约」:成交 214 张券只核销 62 张,待核销 152 张 |
+| 主因(1 条) | 按诊断树从上到下取第一个命中的,决定后面所有动作的方向 |
+| 次要问题(≤ 2 条) | 剩余 B 级按严重度取前 2 条;T 级只作提示,不进主列表 |
+| 整改动作(≤ 3 条) | 主因动作 + 次要问题动作,去重后按优先级排序,每条都带该店真实数字 |

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.