2
0

55 Commity 071d183ed6 ... 940a985131

Autor SHA1 Správa Dátum
  liangzhao 940a985131 merge: 合并 dev 到 main(经营驾驶舱/经营报告/云端数据库/校区地图) 1 týždeň pred
  liangzhao d96785018e fix: 云端数据库密码留空时沿用已保存值 1 týždeň pred
  liangzhao a84beb302d feat: 店铺运营字段排序 + 平台明细缓存 1 týždeň pred
  liangzhao ebb6f14d3f feat: 店铺运营表格支持按字段排序 1 týždeň pred
  liangzhao ca2de06ddc feat: 店铺运营默认展示最近 2 天数据 1 týždeň pred
  liangzhao 07fdb32a2d fix: PostgreSQL 驱动兼容问题(首次实跑暴露) 1 týždeň pred
  liangzhao 3f1b11e751 feat: 系统设置增加云端数据库配置(PostgreSQL) 1 týždeň pred
  liangzhao efd0ba989d chore: 固定换行符(.bat=CRLF,.command/.sh=LF) 2 týždňov pred
  liangzhao 5bf618db9e fix: Windows 启动器改为 CRLF + goto 结构,兼容双击环境 2 týždňov pred
  liangzhao 210777c7a2 fix: 启动器区分平台并给出可读报错 2 týždňov pred
  liangzhao 90dfb0d452 chore: 打包增加运行期模块自检 2 týždňov pred
  liangzhao b9f703bb03 chore: 修复分发包脚本,补齐新增模块与运行环境检查 2 týždňov pred
  liangzhao c7fcfcac1f feat: 导入数据里的新校区自动出现在校区地图 2 týždňov pred
  liangzhao c9e60103f0 feat: 校区地图按省份点亮学校数着色 2 týždňov pred
  liangzhao 0ce1805e53 feat: 经营报告落库缓存,重启后秒开最近一次生成的报告 2 týždňov pred
  liangzhao b133453ba8 feat: 校区地图显示省市名称,学校点按门店数分级着色 2 týždňov pred
  liangzhao d4d1330d3c refactor: 移除经营报告的「图文/交互」双 tab 2 týždňov pred
  liangzhao 7e0ac64c51 feat: 经营报告页增加学校/店铺筛选,默认全部 + 近 7 天 2 týždňov pred
  liangzhao acc2711648 fix: 报告证据字段改用中文,去掉 zeroOrder 等内部字段名 2 týždňov pred
  liangzhao 242c92eb8a feat: 历史报告显示学校/店铺范围,并支持下载 PDF 与 JSON 2 týždňov pred
  liangzhao 2058a069ab fix: 从驾驶舱跳转报告时丢失店铺/学校筛选 2 týždňov pred
  liangzhao d6fab54216 feat: 经营报告中心(保存 / 列表 / 查看 / 删除)+ 默认 AI 生成 2 týždňov pred
  liangzhao 6eaa7cede4 feat: 经营解读与经营报告合并分析内核 2 týždňov pred
  liangzhao ee12aac56f style: 移除驾驶舱的零单药丸条,零单归类并入 AI 经营解读 2 týždňov pred
  liangzhao 7d9e35cacf feat: 经营报告(图文/交互双视图 + A4 打印导出) 2 týždňov pred
  liangzhao 9afc62fcaf fix: 一次点击触发多次上传(监听重复绑定 + 并发竞态) 2 týždňov pred
  liangzhao 9f72a3f263 feat: 导入数据后联动刷新校区地图与经营驾驶舱 2 týždňov pred
  liangzhao f1dcea4390 feat: 零单门店诊断(未开业/闭店/无曝光/无进店/无转化) 2 týždňov pred
  liangzhao 6dd0a95cd1 feat: 驾驶舱接入 AI 经营解读 2 týždňov pred
  liangzhao 2066b4a274 feat: 新增经营驾驶舱(KPI + 趋势/排行/漏斗/结构) 2 týždňov pred
  liangzhao ead0991fc1 docs: 经营驾驶舱设计(6 个 KPI + 趋势/排行/漏斗/结构) 2 týždňov pred
  liangzhao 4a333e6b39 style: 报表改为固定布局填满宽度,去掉右侧空白 2 týždňov pred
  liangzhao fcc3cce228 style: 报表列宽按字段内容自适应 2 týždňov pred
  liangzhao 33c9d781ab style: 表头与单元格按字段类型对齐 2 týždňov pred
  liangzhao 352e756d13 fix: 字段/店铺下拉支持点击空白处与 Esc 关闭 2 týždňov pred
  liangzhao cbdd586f24 feat: 店铺运营支持字段与店铺筛选 2 týždňov pred
  liangzhao 60fcbecf43 style: 店铺运营表格按店铺分块显示 2 týždňov pred
  liangzhao 57b68acff2 feat: 店铺运营报表改单表(一行一店一天,指标做列) 2 týždňov pred
  liangzhao bfb81bdf4e feat: 平台明细并入店铺运营矩阵(人工优先、平台补位) 2 týždňov pred
  liangzhao c41607e6aa feat: 校区关联统计纳入平台明细;店铺运营空态区分平台数据 2 týždňov pred
  liangzhao 437dc96143 feat: 支持平台导出全字段明细导入(124 列)+ 重复校验 2 týždňov pred
  liangzhao 5204186043 feat: 上传弹窗的学校下拉改为可选已登记学校(含内置),保留自动/运营数据学校/手动输入 2 týždňov pred
  liangzhao c4427891e0 feat: 校区地图首个增量(内置浙江工商桐庐校区 + 中国地图点亮) 2 týždňov pred
  liangzhao f0eab490ac docs: 校区地图改为 JSON 静态存储(种子+运行时),内置浙江工商桐庐校区 2 týždňov pred
  liangzhao fd0614f59f docs: 校区地图确认未收录兜底与跳转联动 2 týždňov pred
  liangzhao caecfadbdb docs: 校区地图改为「输入名称自动定位」(含 2820 所高校 GCJ-02 坐标库) 2 týždňov pred
  liangzhao a61f681db9 docs: 全国校区地图设计(主动点选定位 + GCJ-02 + ECharts 点亮)+ 底图/城市坐标数据 2 týždňov pred
  liangzhao c73be89651 feat: 店铺运营支持一次上传多个 xlsx 2 týždňov pred
  liangzhao 7006aa2871 feat: 上传时可选学校 + 同文件强制重传替换旧批次 2 týždňov pred
  liangzhao 69daecce17 fix: 建表后也做 checkpoint,空库阶段 data/ops.db 自包含 2 týždňov pred
  liangzhao 9ed909fe00 fix: Node < 22.5 时明确返回 sqlite-unavailable(前端提示升级而非笼统的连接失败) 2 týždňov pred
  liangzhao c40593a724 fix: SQLite 写事务后 checkpoint,ops.db 自包含便于备份 2 týždňov pred
  liangzhao a5368115b6 feat: 店铺运营数据上传(xlsx 解析 + SQLite 存储 + 报表/卡片视图) 2 týždňov pred
  liangzhao 2cd9502fdb docs: 存储层改为双驱动(本地 SQLite 先行 / 云端 PG 可切换) 2 týždňov pred
  liangzhao 11e730a3bd docs: 店铺运营数据上传设计(云端 PG 存储 + 矩阵/卡片视图) 2 týždňov pred
49 zmenil súbory, kde vykonal 9288 pridanie a 41 odobranie
  1. 6 0
      .gitattributes
  2. 2 0
      .gitignore
  3. 48 2
      README.md
  4. 22 1
      config.example.json
  5. 125 0
      docs/superpowers/plans/2026-10-20-manual-ops-import.md
  6. 333 0
      docs/superpowers/specs/2026-10-20-campus-map-design.md
  7. 604 0
      docs/superpowers/specs/2026-10-20-manual-ops-import-design.md
  8. 128 0
      docs/superpowers/specs/2026-10-20-ops-cockpit-design.md
  9. 416 0
      ops-import.js
  10. 147 0
      ops-store.js
  11. 150 0
      package-lock.json
  12. 10 1
      package.json
  13. 89 0
      public/app.js
  14. BIN
      public/assets/logo.png
  15. 456 0
      public/cockpit.js
  16. 178 0
      public/index.html
  17. 110 0
      public/multi-select.js
  18. 882 0
      public/ops.js
  19. 620 0
      public/report.js
  20. 342 0
      public/schools.js
  21. 304 0
      public/style.css
  22. 0 0
      public/vendor/china-map.json
  23. 0 0
      public/vendor/cn-cities.json
  24. 0 0
      public/vendor/cn-universities.json
  25. 98 0
      schools-store.js
  26. 76 0
      scripts/db-export.js
  27. 115 0
      scripts/db-import.js
  28. 24 0
      scripts/db-init.js
  29. BIN
      scripts/fixtures/ops-manual-sample.xlsx
  30. BIN
      scripts/fixtures/platform-export-sample.xlsx
  31. BIN
      scripts/fixtures/platform-export-variant.xlsx
  32. 54 2
      scripts/smoke.js
  33. 563 0
      scripts/test-ops-import.js
  34. 108 0
      scripts/test-schools.js
  35. 12 0
      server/context.js
  36. 2 0
      server/routes/index.js
  37. 1565 0
      server/routes/ops.js
  38. 133 0
      server/routes/schools.js
  39. 102 0
      server/routes/settings.js
  40. 8 0
      server/routes/static.js
  41. 21 0
      server/schools.seed.json
  42. 238 0
      server/services/xlsx.js
  43. 463 0
      server/stores/ops-pg.js
  44. 504 0
      server/stores/ops-sqlite.js
  45. 73 5
      启动.bat
  46. 48 4
      启动.command
  47. 9 2
      启动.sh
  48. 41 6
      打包分发.bat
  49. 59 18
      打包分发.command

+ 6 - 0
.gitattributes

@@ -0,0 +1,6 @@
+# Windows 启动脚本必须 CRLF,否则 cmd 解析异常(窗口一闪而过)
+*.bat   text eol=crlf
+*.cmd   text eol=crlf
+# macOS / Linux 脚本保持 LF
+*.command text eol=lf
+*.sh      text eol=lf

+ 2 - 0
.gitignore

@@ -10,3 +10,5 @@ data/
 .v2c/
 .video_agent/
 .zcode/
+ops-export.json
+dist/

+ 48 - 2
README.md

@@ -43,9 +43,32 @@
 - **智能问数对话记忆**:问数对话按数据主体保存在浏览器本地,刷新或重开页面后可继续追问,「清空对话」同时清除本地记录。
 - **字段**:日期、门店名称、有效订单、收入、营业额、曝光人数、下单人数、进店转化率、下单转化率、客单价、支出、活动订单、活动补贴等;「营业时长 / 异常关店时长 / 商品销售额」为预留列,待后续数据源接入后自动回填。
 
+### 店铺运营数据导入
+
+采集之外的另一条数据入口:直接上传商家后台导出的人工统计表,不依赖商家客户端。
+
+- **支持格式**(自动识别,两种可混着传):
+  - **人工统计横表**:`学校名称 / 店铺名称 / ID / 上线状态` + 每天 4 列(订单量 / 营收 / 曝光量 / 进店转换率),日期列写成 `9.9订单量`,年份在上传时选。
+  - **平台导出全字段明细**:一行一店一天,含 `日期 / 门店编号 / 门店名称` 与 100+ 列指标(营业额、有效订单、曝光、评分等),全部原样入库。
+- **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
+- **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
+- **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算;下方「零单诊断」用平台字段区分未开业 / 当日闭店 / 无曝光 / 无进店 / 无转化。
+- **报告中心**:报告页可「保存报告」,历史列表显示保存时间、区间、学校、店铺范围与来源(AI/规则),支持查看、下载 PDF(浏览器打印)、下载 JSON 与删除;保存的是完整 Report JSON 快照(含 AI 文字与当时数值),数据后续变化不影响已保存版本。
+- **解读与报告同源**:驾驶舱的「经营解读」是经营报告 JSON 的摘要视图(同一份生成结果、同一缓存),卡片上可一键跳到同区间的完整报告;报告页「重新生成」会调用 AI 补齐文字,模型不可用时自动退回规则版并标注。
+- **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
+- **经营报告**:按统一 Report JSON 渲染,图表可交互(悬浮提示),支持日期/学校/店铺筛选、重新生成、浏览器打印导出 PDF(A4 打印样式已适配)。报告含管理层摘要(带证据)、核心指标、经营/门店/流量/费用四节图表、风险与行动计划、数据质量与方法论。
+- **字段与店铺筛选**:字段下拉可勾选平台导出里的任意列(默认 4 个基础指标),勾几个就显示几列;店铺下拉可多选门店,只看选中的店;选择保存在浏览器本地。
+- **学校归属**:平台表里没有学校列时,优先用上传时选的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」匹配到已登记学校);都匹配不到会要求先选学校。
+- **原件存档**:每次上传的 xlsx 原样入库,可在「导入记录」里下载回;解析规则升级后可「重新解析」而不必重传。
+- **指标矩阵**:行=店铺(按学校分组)、列=日期,可按指标分区块查看;也支持卡片视图(区间合计 + 迷你趋势)。学校、日期、指标都可筛选,支持导出 CSV。
+- **脏数据容错**:`10..6%`、`12.3`(应为 12.3%)、`未开放`、空白等都会按规则清洗并带标记(表格里显示 ⚠,悬停看原始值),不会因为一格填错整批失败。
+- **存储**:默认本地 SQLite(`data/ops.db`),不引入额外依赖。改 `config.json` 的 `storage.driver` 为 `postgres` 即可切到云端库(需 `npm i pg`、填连接串、`npm run db:init` 建表,`db:export` / `db:import` 搬迁数据)。
+- **降级**:数据库不可用时只有本菜单提示不可用,采集、生成、问数等其它功能照常。
+
 ## 环境要求
 
 - Node.js 18 或更高版本。
+- **店铺运营数据导入需要 Node.js 22.5 或更高版本**(依赖内置 `node:sqlite`);低版本时该菜单提示不可用,其余功能不受影响。
 - 可访问图片生成服务的 API Key。
 - 如需采集功能,需要具备淘宝闪购后台访问权限的有效 Cookie(必须包含 `ksid`)。
 
@@ -79,6 +102,21 @@
 
 5. 打开 `http://localhost:5177`。若修改了 `port`,请访问对应端口。
 
+## 云端数据库(PostgreSQL)
+
+默认用本地 SQLite(`data/ops.db`)。需要多台机器共用一份数据时,在「系统设置 → 5 云端数据库」里切到 PostgreSQL:
+
+1. 填主机 / 端口 / 数据库 / 用户 / 密码,点「测试连接」;
+2. 保存后**重启服务**生效(启动时才会建立连接池);
+3. 已有数据迁移:
+   ```bash
+   npm run db:export ops-export.json --with-files   # 从本地 SQLite 导出
+   # 切换到 PostgreSQL 并重启
+   npm run db:import ops-export.json                # 导入云端(目标库应为空)
+   ```
+
+安全建议:5432 不要对 0.0.0.0/0 开放,腾讯云安全组里只放行自己的出口 IP;密码用随机强口令(设置页只显示「已设置」)。
+
 ## 系统设置
 
 | 配置 | 说明 |
@@ -155,6 +193,8 @@ workspace/
 │   ├── middleware.js      # 标准错误边界:统一异常 → JSON 响应(含错误码映射)
 │   ├── context.js         # 共享状态装配:config / session / 店铺清单 / 路径守卫 / 日志
 │   ├── util.js            # 无状态工具:响应、请求体、文件名、静态资源常量
+│   ├── stores/            # 存储驱动(ops-sqlite.js / ops-pg.js)
+│   ├── services/xlsx.js   # 零依赖 xlsx 读取器(ZIP + XML)
 │   ├── routes/            # 薄 HTTP 层(按业务域拆分)
 │   │   ├── health.js      #   健康检查 / 默认根目录
 │   │   ├── session.js     #   采集 Cookie 会话
@@ -163,6 +203,7 @@ workspace/
 │   │   ├── generate.js    #   视觉生成、任务中心(含采集任务)、画板保存、打开目录
 │   │   ├── history.js     #   门店扫描(图片尺寸缓存)、历史创作、图片文件服务
 │   │   ├── reports.js     #   数据采集 + 智能问数(同步/查询/导出/周报)
+│   │   ├── ops.js         #   店铺运营数据:上传/解析/矩阵查询/原件下载
 │   │   ├── static.js      #   静态前端(精确白名单 + /assets/)
 │   │   └── index.js       #   路由注册总入口
 │   └── services/          # 有状态引擎
@@ -208,15 +249,20 @@ workspace/
 | `config.json` | API Key、模型、端口等 | 系统设置保存时写入;首次启动若缺失会自动从模板生成 |
 | `session.json` | 淘宝闪购采集 Cookie(含 `ksid`) | 仅门店采集使用;界面保存后不回显 |
 | `data/reports/` | 经营数据本地数据湖 | 各主体经营数据,按「日期 × 门店」沉淀 |
+| `data/ops.db` | 店铺运营数据(上传解析) | SQLite;含原件与解析行,备份 `data/` 时一并保留 |
 | `manual-shops.json` | 手动店铺清单 | 门店采集用 |
 
 以上文件均不进 Git;数据采集的取数不依赖 Cookie —— 它实时读取本机商家客户端的登录态(调试端口 9669)。
 
 ### 发给别人部署(分发包)
 
-双击 **打包分发.command**(macOS/Linux)或 **打包分发.bat**(Windows),会在项目上级目录生成 `智慧运营中台-分发包.zip`。包内只含代码、依赖和配置模板,**不含**你的 `config.json` / `session.json` / `manual-shops.json` / `data/`(API Key、Cookie、报表数据只留在你本机)。
+双击 **打包分发.command**(macOS/Linux)或 **打包分发.bat**(Windows),会在项目内 `dist/` 生成 `智慧运营中台-分发包.zip`。包内只含代码、依赖和配置模板,**不含**你的 `config.json` / `session.json` / `manual-shops.json` / `data/` / `workspace/`(API Key、Cookie、报表数据只留在你本机)。
+
+需要让对方直接看到演示数据时,执行 `./打包分发.command --with-data`(会把 `data/ops.db` 一起打进去,**注意先确认数据可外发**)。
+
+> 运行要求 **Node.js >= 22.13**(内置 `node:sqlite`);启动脚本会先做版本检查。
 
-对方解压后双击 `启动.command`(macOS)或 `启动.bat`(Windows)即可:首次启动自动生成其本机 `config.json`,在「系统设置」中填写自己的 API Key;数据采集页按提示以调试端口启动并登录自己的商家客户端后同步取数。
+对方解压后按平台双击对应启动器:**Windows 用 `启动.bat`**,**macOS 用 `启动.command`**(`.command` 是 macOS 脚本,在 Windows 上无法运行;`.bat` 在 macOS 上也无效)。启动器会自检 Node 版本、自动装依赖,出错时窗口会停住显示原因。首次启动自动生成其本机 `config.json`,首次启动自动生成其本机 `config.json`,在「系统设置」中填写自己的 API Key;数据采集页按提示以调试端口启动并登录自己的商家客户端后同步取数。
 
 **升级已装过旧版的电脑**:把分发包解压覆盖到原目录即可(不要先删旧目录)。包内不含配置文件,覆盖不会动他们的历史 `config.json` / `session.json`;新版启动时会把历史配置与默认值合并读取 —— API Key、模型、Cookie 全部原样沿用,无需重填;若历史 `rootDir` 在本机不可用,启动时会提示到「系统设置」修改,不会崩溃。
 

+ 22 - 1
config.example.json

@@ -3,9 +3,30 @@
   "apiKey": "your-api-key-here",
   "apiBase": "https://api.lk888.ai",
   "model": "gpt-image-2",
+  "chatModel": "deepseek-v4.1-flash",
+  "chatApiBase": "",
+  "chatApiKey": "",
   "maxConcurrentJobs": 4,
   "pageSize": 50,
   "delayMs": 300,
   "crawlChannel": "auto",
-  "requestTimeoutMs": 20000
+  "requestTimeoutMs": 20000,
+  "rootDir": "workspace",
+  "storage": {
+    "driver": "sqlite",
+    "sqlite": {
+      "file": "data/ops.db"
+    },
+    "postgres": {
+      "host": "",
+      "port": 5432,
+      "database": "smart_ops",
+      "user": "smart_ops",
+      "password": "",
+      "ssl": false,
+      "schema": "public",
+      "max": 5,
+      "connectTimeoutMs": 10000
+    }
+  }
 }

+ 125 - 0
docs/superpowers/plans/2026-10-20-manual-ops-import.md

@@ -0,0 +1,125 @@
+# 店铺运营数据上传 · 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让用户上传商家后台/人工统计的 `店铺运营数据.xlsx`,原件入库、解析成结构化行,并在新增「店铺运营」页按指标矩阵/卡片查看。
+
+**Architecture:** 上传走 `POST /api/ops/upload`(base64)→ `ops-import.js` 识别并清洗 → `ops-store.js` 统一接口 → 本地 SQLite(`node:sqlite`,`data/ops.db`)。云端 PG 驱动留同接口的壳,本期不装依赖。前端 `public/ops.js` 独立视图,复用现有 `.view` / `.btn` / 表格样式。
+
+**Tech Stack:** Node 22.23(内置 `zlib` / `node:sqlite`)、原生前端 JS、零新增依赖。
+
+## Global Constraints
+
+- Node ≥ 22.5(`node:sqlite` 要求);低于此版本只有「店铺运营」不可用,其余功能照常
+- 本期不新增 npm 依赖;`pg` 只在 PG 驱动里懒加载,未安装时给出明确错误
+- 数据文件:`data/ops.db`(`data/` 已在 `.gitignore`)
+- 所有新接口前缀 `/api/ops/`,沿用 `originGuard` / `tokenGuard`
+- 界面文案中文,样式复用现有 `.page-header` / `.btn` / `.card` / `table`
+- 每完成一个任务提交一次,提交信息用 `feat: ...` / `test: ...`
+
+## 文件结构
+
+| 文件 | 责任 |
+|---|---|
+| `server/services/xlsx.js` | ZIP+XML → `{ sheets: [{ name, rows }] }`,纯函数 |
+| `ops-import.js` | 解析器注册表;`manual-ops` 识别/解析/清洗;输出行与警告 |
+| `server/stores/ops-sqlite.js` | SQLite 实现:建表、事务、upsert、查询、原件读写 |
+| `server/stores/ops-pg.js` | PG 实现壳(懒加载 `pg`),本期不启用 |
+| `ops-store.js` | 读 `config.storage`、选驱动、暴露统一接口与降级状态 |
+| `server/routes/ops.js` | 7 个端点 + 降级响应 |
+| `public/ops.js` | 页面:工具条、矩阵、卡片、上传弹窗、导入记录 |
+| `scripts/test-ops-import.js` | 解析器单测 + 端到端接口测试(临时库) |
+| `scripts/db-init.js` / `db-export.js` / `db-import.js` | 建表与数据搬迁 |
+| `scripts/fixtures/ops-manual-sample.xlsx` | 脱敏样例(36 店 × 29 天) |
+
+## Task 1: xlsx 读取器
+
+**Files:** Create `server/services/xlsx.js`; Test `scripts/test-ops-import.js`(先建最小用例)
+
+**Interfaces:**
+- Produces: `readXlsx(buffer) → { sheets: [{ name, rows: (string|number|null)[][] }] }`,`rows[0]` 为第一行;失败抛 `Error` 且 `err.code = 'XLSX_PARSE_FAILED'`
+
+- [ ] 写失败用例:`readXlsx(Buffer.from('not a zip'))` 应抛 `XLSX_PARSE_FAILED`
+- [ ] 跑测试确认失败
+- [ ] 实现:定位 ZIP EOCD(`0x06054b50`)→ 中央目录 → 取 `xl/workbook.xml`、第一个 sheet、`xl/sharedStrings.xml` → `zlib.inflateRawSync`(stored 直接切片)→ 正则取 `<row r="N">`、`<c r="A1" t="s|inlineStr|str|b"><v>`、`<is><t>` → 共享字符串按索引取 → 按列字母换算列号补空 → 返回二维数组
+- [ ] 用真实文件 `c = readXlsx(fs.readFileSync('<Desktop>/店铺运营数据.xlsx'))` 断言 `rows.length === 43`、`rows[0].length === 122`、`rows[1][3] === '千里香馄饨(康诺餐厅店)'`
+- [ ] 提交 `feat: xlsx 读取器(零依赖)`
+
+## Task 2: 人工统计表解析器
+
+**Files:** Create `ops-import.js`; Create `scripts/fixtures/ops-manual-sample.xlsx`(用 python openpyxl 脱敏生成:店名 `样例店 NN`、学校保留 3 个、数值按原形态含 `10..6%`/`12.3`/`未开放`/空);Test 扩展 `scripts/test-ops-import.js`
+
+**Interfaces:**
+- Produces: `detect(cells) → 'manual-ops' | null`;`importManualOps(cells, { year }) → { rows, shops, warnings, dates }`
+- `rows[i] = { date:'YYYYMMDD', shopKey, shopId, shopName, school, status, orderCnt, revenue, exposure, clickRate, raw:{...}, quality:[...] }`
+- `warnings[i] = { row, column, raw, reason }`
+
+- [ ] 写用例:表头识别(`9.9订单量` 等)→ `'manual-ops'`;`9.9`+2026 → `20260909`;`10..6%` → `0.106` + `malformed-percent`;`12.3` → `0.123` + `percent-scaled`;`未开放` → `null` + `non-numeric`;空 → `null` + `missing`;占位行(无店名)不产生行;无 ID 店 `shopKey = school|shopName` + `no-shop-id`;总行数 = 店数 × 29
+- [ ] 跑测试确认失败
+- [ ] 实现 `detect`(表头含「订单量/营收/曝光量」≥2 且含「转换率」)、`parseHeader`(按名定位,容忍空格)、`parseDate`(月.日 + year,跨年 +1)、`clean`(六步清洗)、`buildRows`(跳过空店名/全空行)
+- [ ] 跑测试确认通过
+- [ ] 提交 `feat: 人工统计表解析器`
+
+## Task 3: 存储层(SQLite + 统一接口)
+
+**Files:** Create `server/stores/ops-sqlite.js`、`ops-store.js`、`server/stores/ops-pg.js`;Modify `server/context.js`(`storage` 默认值)
+
+**Interfaces:**
+- `createStore(config, { rootDir }) → { kind, init(), health(), withTransaction(fn), insertUpload(tx, upload), upsertRows(tx, rows), upsertShops(tx, shops), findUploadBySha(sha), listUploads(), getUpload(id), getContent(id), updateReport(id, report), queryRows({from,to,schools,shops,metrics}), summary() }`
+- `upload = { id, originalName, sha256, sizeBytes, uploadedAt, year, parserKind, parserVersion, report, content:Buffer }`
+- `init()` 幂等;`health() → { ok, kind, detail }`
+
+- [ ] 写用例(临时库):`init()` 两次不报错;事务内写 upload+rows 后 `queryRows` 可读;`ON CONFLICT` 覆盖同 `date|shop_key`;事务抛错后无残留
+- [ ] 实现 `ops-sqlite.js`:`new DatabaseSync(file)`,`PRAGMA journal_mode=WAL`、`busy_timeout`、`foreign_keys=ON`;DDL 见设计文档 §5.3;`withTransaction` 用 `BEGIN/COMMIT/ROLLBACK`
+- [ ] `ops-store.js`:读 `config.storage`(默认 `sqlite`),懒加载驱动,缓存 `init` 的 promise;`kind`/`health` 暴露给路由;失败不抛到进程
+- [ ] `ops-pg.js`:同接口,`require('pg')` 失败时 `health() = { ok:false, detail:'pg-not-installed' }`
+- [ ] 跑测试通过;提交 `feat: ops 存储层(SQLite 驱动 + PG 壳)`
+
+## Task 4: HTTP 端点
+
+**Files:** Create `server/routes/ops.js`;Modify `server/routes/index.js`
+
+**Interfaces:**
+- `POST /api/ops/upload {fileName, contentBase64, year, force}` → §7.1
+- `GET /api/ops/summary`、`/api/ops/rows`、`/api/ops/uploads`、`/api/ops/uploads/:id/file`、`POST /api/ops/uploads/:id/reparse`、`GET /api/ops/export`
+
+- [ ] 写用例:空态 summary 200;上传 200 且 `content` sha256 一致;重复 409;`force` 覆盖;查询过滤;下载字节一致;非法 id 400;reparse 行数不变
+- [ ] 实现:`readBody` 取 base64 → `Buffer.from(b64,'base64')`;校验大小/类型/sha256;`opsImport` → `store.withTransaction`;`sendJson` 统一错误码(400/409/413/503/500)
+- [ ] 降级:`store.health().ok === false` 时 ops 接口 503,`summary` 返回 `{enabled:false, reason, detail}`
+- [ ] 注册到 `server/routes/index.js`(`ops` 放在 `reports` 之后、`static` 之前)
+- [ ] 跑测试通过;提交 `feat: /api/ops 端点`
+
+## Task 5: 前端页面
+
+**Files:** Create `public/ops.js`;Modify `public/index.html`(菜单 + `viewOps`)、`public/style.css`(矩阵/卡片少量样式)、`public/app.js`(`views` 映射 + `shanOpsInit` 钩子)
+
+**Interfaces:**
+- `window.shanOpsInit()` 首次激活时拉 `summary`,渲染工具条与空态
+- 指标键固定 `order_cnt|revenue|exposure|click_rate`,默认全选
+
+- [ ] 菜单:`data-view="ops"`,放在「数据采集」后
+- [ ] 页面骨架:`page-header` + 工具条(上传、学校、日期、指标、视图切换、导出)+ 内容区 + 上传弹窗 + 记录抽屉
+- [ ] 矩阵渲染:按学校分组、指标分区块、首列 sticky、`quality` 角标 `⚠`(title 带原文)
+- [ ] 卡片渲染:区间合计 + SVG 迷你折线
+- [ ] 上传:`<input type="file" accept=".xlsx">` → FileReader base64 → POST → 报告;409 提示;503 提示
+- [ ] 导出:拼查询串打开 `/api/ops/export?...`
+- [ ] 手测:上传真实文件,切指标/学校/日期,两种视图,导出 CSV
+- [ ] 提交 `feat: 店铺运营页面`
+
+## Task 6: 工具脚本、smoke、文档
+
+**Files:** Create `scripts/db-init.js`、`scripts/db-export.js`、`scripts/db-import.js`;Modify `scripts/smoke.js`、`package.json`(`test:ops`、`db:*`)、`README.md`
+
+- [ ] `db-init.js`:`createStore(config).init()` 后打印驱动与表
+- [ ] `db-export.js`:SQLite → JSON(uploads 元数据 + rows + shops,不含 content 或含 base64 可选)
+- [ ] `db-import.js`:JSON → 目标驱动
+- [ ] smoke 增加三个只读断言(summary/uploads/rows 均 200)
+- [ ] README 新增章节:功能说明、Node 版本要求、数据文件位置、备份、切 PG 步骤
+- [ ] `npm run test:ops` 全绿;`npm run smoke` 全绿
+- [ ] 提交 `feat: 店铺运营数据导入(脚本/冒烟/文档)`
+
+## 验收
+
+- `npm run test:ops`:解析器 + 端到端用例全过(临时库,不依赖网络)
+- `npm run smoke`:原有断言 + 新增 3 条全过
+- 手工:上传 `店铺运营数据.xlsx` → 36 店 × 29 天 → 切换指标/学校 → 卡片与矩阵 → 导出 CSV → 重传 409 → 重启服务数据仍在

+ 333 - 0
docs/superpowers/specs/2026-10-20-campus-map-design.md

@@ -0,0 +1,333 @@
+# 全国校区地图 · 设计文档(v2:自动定位版)
+
+- 日期:2026-10-20
+- 分支:`dev`
+- 状态:待评审(本阶段只出设计,不写实现)
+- 变更:**取消**「选省/市 + 地图点选」流程,改为**输入学校名自动定位**
+- v3 变更:学校数据改为 **JSON 静态存储**(种子文件 + 运行时文件),不再用数据库;内置「浙江工商桐庐校区」
+
+## 1. 背景与目标
+
+已有「店铺运营」按学校分组看数据,但缺少全国视角。
+
+新增一级菜单 **校区地图**:
+
+- 中国地图上展示所有学校;**启用的学校点亮**(涟漪 + 名称),停用的显示为灰点
+- 界面上**创建学校**:只填名称,系统自动定位并点亮
+- 列表与「店铺运营」数据关联,显示该学校的门店数与最近数据日期
+- 删除有运营数据的学校时二次确认
+
+## 2. 关键决策
+
+| 决策 | 结论 |
+|---|---|
+| 位置来源 | **本地高校坐标库自动匹配**(2820 所),不选省市、不点地图、不填经纬度 |
+| 未收录的学校 | ① 在线地理编码兜底(Photon)→ ② 仍失败则创建为「未定位」,不点亮,列表可点「重新定位」重试 |
+| 坐标系 | 统一 **GCJ-02**(底图坐标系);数据源是 BD-09 需转换,在线兜底是 WGS84 需转换 |
+| 省份 | 由坐标预先算好(点落省份多边形),不需要用户选 |
+| 存储 | **JSON 文件**(种子 + 运行时),不依赖数据库;Node 18 也可用 |
+| 内置学校 | 预置「浙江工商大学桐庐校区」(别名:浙江工商桐庐校区等) |
+| 批量导入 | 不做 |
+| 删除保护 | 有同名运营数据时二次确认 |
+
+## 3. 交互设计
+
+### 3.1 创建学校
+
+```
+[新建学校] → 弹窗:输入「学校名称」(即时联想)
+                 ┌──────────────────────────────┐
+                 │ 中国计量大学     浙江省        │ ← 本地库命中
+                 │ 中国计量大学现代科技学院  浙江省 │
+                 └──────────────────────────────┘
+        ↓ 选中
+   地图自动飞到该点(zoom 8)→ 落标记(脉冲)→ 底部确认卡:
+        ┌──────────────────────────────────────┐
+        │ 中国计量大学 · 浙江省                  │
+        │ 坐标 120.3626, 30.3210(GCJ-02)      │
+        │              [取消]  [创建并点亮]      │
+        └──────────────────────────────────────┘
+        ↓
+   创建成功 → 点位橙色涟漪 + 学校名,列表新增一条
+```
+
+规则:
+
+- **精确且唯一命中**(如「中国计量大学」):直接显示确认卡,一次点击完成
+- **多条命中**(如「中国计量大学」与「中国计量大学现代科技学院」):候选列表选择,带省份区分
+- **无命中**:显示「未收录该学校」+ `[尝试在线定位]`;在线也失败时提供 `[先创建(未定位)]`
+- 创建默认**启用**(点亮),可立即关闭
+
+### 3.2 编辑
+
+点列表项 → 右侧详情卡:
+
+```
+┌ 中国计量大学 ──────────────────┐
+│ 浙江省 · 120.3626, 30.3210      │
+│ 运营数据:9 家门店 · 最近 10-07  │
+│ [重新匹配]  [停用]  [删除]       │
+└────────────────────────────────┘
+```
+
+- **重新匹配**:回到搜索框,输入新名称重新定位(覆盖坐标)
+- 改名:详情卡标题可编辑
+- 不提供拖动或点选微调(遵循"不点地图"的要求)
+
+### 3.3 删除保护
+
+若「店铺运营」中存在同名学校:
+
+```
+确定删除「中国计量大学」?
+店铺运营中有 9 家门店使用了该名称,最近数据日期 2026-10-07。
+删除学校不会删除运营数据。
+[取消] [仍然删除]
+```
+
+### 3.4 地图交互
+
+| 操作 | 行为 |
+|---|---|
+| 滚轮 / 双指 | 缩放(zoom 3~12) |
+| 拖拽 | 平移 |
+| 悬停点位 | tooltip:学校名 · 省份 · 门店数 |
+| 点击点位 | 选中并高亮,右侧滚动到对应卡片 |
+| 搜索框(列表上方) | 过滤列表;点结果地图飞过去 |
+| [回到全国] | 恢复默认视野 |
+
+### 3.5 空态与异常
+
+| 场景 | 展示 |
+|---|---|
+| 没有学校 | 地图灰显 + 中央「创建第一所学校」 |
+| 存储不可用 | 地图正常渲染 + 顶部提示「学校数据暂不可用」+ 重试 |
+| 底图加载失败 | 提示刷新,不渲染空画布 |
+| 名称重复 | 保存时提示「已存在同名学校」 |
+| 未定位学校 | 列表中灰色标签「未定位」,可重新定位 |
+
+## 4. 定位数据与算法
+
+### 4.1 本地高校库
+
+`public/vendor/cn-universities.json`(238KB,**2820 所**):
+
+```json
+{
+  "中国计量大学": { "lng": 120.362644, "lat": 30.321047, "province": "浙江省" },
+  "浙江科技学院": { "lng": 120.025229, "lat": 30.220034, "province": "浙江省" }
+}
+```
+
+生成方式(构建期一次性;数据已生成,生成脚本在实施阶段一并提供):
+
+1. 取开源数据集 `flwfdd/china-university-location`(2820 所,百度坐标)
+2. **BD-09 → GCJ-02** 转换
+3. 用省级 GeoJSON 做点在多边形内判断,写入 `province`
+4. 输出精简 JSON(不含冗余字段)
+
+**坐标系验证**(以 Photon 返回的 WGS84 → GCJ-02 为参照):
+
+| 学校 | 数据集原始(BD-09) | 转换后 GCJ-02 | 与参照偏差 |
+|---|---|---|---|
+| 清华大学 | 116.33337, 40.00964 | 116.32677, 40.00391 | 45m |
+| 北京大学 | 116.31683, 39.99888 | 116.31031, 39.99288 | 33m |
+| 武汉大学 | 114.37292, 30.54380 | 114.36651, 30.53748 | 274m |
+| 中国计量大学 | 120.36904, 30.32740 | 120.36264, 30.32105 | 284m |
+| 复旦大学 | 121.51077, 31.30187 | 121.50419, 31.29623 | 392m |
+
+不转换直接用会是 **850~1200m** 的系统性偏移,所以转换是必须的。残差在校区尺度内(校区本身就有几百米跨度),可接受。
+
+### 4.2 名称匹配
+
+按顺序尝试,命中即止:
+
+1. 精确匹配(去首尾空格)
+2. 归一化匹配:去掉括号内容、空格、全半角差异(`中国计量大学(下沙校区)` → `中国计量大学`)
+3. 前缀/包含匹配(`计量大学` → `中国计量大学`)
+
+候选排序:精确 > 归一化 > 包含;同分时名称短的优先。最多返回 8 条,带省份用于区分。
+
+### 4.3 未收录兜底
+
+在线地理编码(服务端发起,避免浏览器跨域):
+
+- 请求 `https://photon.komoot.io/api/?q=<名称>&limit=5`
+- 只接受 `countrycode=CN` 且 `name` 与输入相似度足够高的结果
+- 返回 WGS84 → **WGS84→GCJ-02 转换**后使用
+- 超时 8s;失败则创建为「未定位」
+
+设计上把「在线兜底」做成**可选动作**(用户点按钮触发),不做成创建时自动请求——避免无网络环境下创建流程卡住。
+
+### 4.4 点亮
+
+```
+geo(省级底图,roam)
+├── effectScatter「已启用」:橙色 #ff6b35,rippleEffect,右侧名称标签
+└── scatter「未启用 / 未定位」:灰色 #b8b2ad,无标签
+```
+
+点击、悬停、选中联动;同屏像素距离 <14px 的两点做 ≤8px 确定性偏移。
+
+## 5. 数据模型(JSON 静态存储)
+
+### 5.1 两类文件
+
+| 文件 | 是否提交到仓库 | 作用 |
+|---|---|---|
+| `server/schools.seed.json` | **提交** | 内置学校(随版本发布);初始只有浙江工商桐庐校区 |
+| `data/schools.json` | 不提交(`data/` 已 gitignore) | 运行时新增/修改的学校,以及对内置项的覆盖 |
+
+读取时**合并**两者,对外的列表就是合并结果;写入只动 `data/schools.json`。
+
+### 5.2 条目结构
+
+```json
+{
+  "id": "sch_tonglu_001",
+  "name": "浙江工商大学桐庐校区",
+  "aliases": ["浙江工商桐庐校区", "浙江工商大学杭州商学院", "杭州商学院桐庐校区"],
+  "province": "浙江省",
+  "city": "杭州市",
+  "county": "桐庐县",
+  "lng": 119.744166,
+  "lat": 29.830417,
+  "source": "seed",
+  "seed": true,
+  "enabled": true,
+  "created_at": "2026-10-20T00:00:00.000Z",
+  "updated_at": "2026-10-20T00:00:00.000Z"
+}
+```
+
+字段说明:
+
+- `name` 为唯一键;`aliases` 只用于**搜索联想**,不参与唯一性
+- `lng/lat`(GCJ-02)为 `null` 表示「未定位」(灰点、不点亮)
+- `seed: true` 表示内置项
+- 内置项坐标:浙江工商大学杭州商学院桐庐校区(桐庐县),`[119.744166, 29.830417]`
+
+### 5.3 合并与写回规则
+
+| 操作 | 行为 |
+|---|---|
+| 读取 | 种子 + 运行时;同名(`name`)时运行时字段覆盖种子 |
+| 新建 | 只写 `data/schools.json`;若与种子/运行时同名 → 400 |
+| 修改(改名/重新定位/启停) | 写 `data/schools.json` 的同名记录(对种子项即"覆盖层") |
+| 删除 | 运行时项:从文件移除;**种子项:拒绝删除**,提示「内置学校只能停用」 |
+| 文件缺失/损坏 | 用种子继续工作,日志 warning,不崩溃 |
+| 写入 | 临时文件 + rename 原子替换;单进程串行 |
+
+### 5.4 与运营数据的关联
+
+学校存 JSON,运营数据在 ops 库(SQLite/PG),关联在读取时做:
+
+- 按 `ops_school_name`(即学校名及别名)去 `ops_shop.school` 精确匹配
+- 聚合出「门店数」与「最近数据日期」
+- **ops 库不可用时**:地图与学校管理照常,关联字段显示「未关联运营数据」
+
+### 5.5 内置学校
+
+| 字段 | 值 |
+|---|---|
+| 名称 | 浙江工商大学桐庐校区 |
+| 别名 | 浙江工商桐庐校区 / 浙江工商大学杭州商学院 / 杭州商学院桐庐校区 |
+| 省/市/县 | 浙江省 / 杭州市 / 桐庐县 |
+| 坐标(GCJ-02) | 119.744166, 29.830417 |
+| 默认状态 | 启用(点亮) |
+
+> 名称采用「浙江工商大学桐庐校区」是为了和上传表里的 `学校名称` 对得上(人工统计表用的就是全称),别名保证用户输入简称也能搜到。
+
+## 6. 接口
+
+| 方法 | 路径 | 说明 |
+|---|---|---|
+| GET | `/api/schools` | 列表,含 enabled、坐标、门店数、最近数据日期 |
+| GET | `/api/schools/search?q=` | 本地高校库联想(也可前端直接加载 JSON 搜索,二选一) |
+| POST | `/api/schools` | 创建:`{ name, lng?, lat?, province?, source? }`;缺坐标时按名称自动匹配 |
+| POST | `/api/schools/:id` | 更新(改名 / 重新匹配 / 坐标) |
+| POST | `/api/schools/:id/toggle` | 启用/停用 |
+| POST | `/api/schools/locate` | 在线兜底:`{ name }` → `{ lng, lat, province, source }` |
+| DELETE | `/api/schools/:id` | 删除 |
+
+校验:名称 1~60 且唯一;坐标若提供必须在 GCJ-02 中国范围(lng 73~136,lat 3~54)。
+
+存储相关:写操作失败(磁盘只读等)返回 500 并保持文件原样;`GET /api/schools` 始终可用(JSON 不依赖数据库)。测试通过环境变量 `SCHOOLS_FILE` 指定临时文件,避免污染本机 `data/schools.json`。
+
+## 7. 界面
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ 校区地图                          + 新建学校                 │
+│ 已启用 3 / 共 5                                              │
+├───────────────────────────────────┬──────────────────────────┤
+│                                   │ 🔍 搜索                   │
+│        中国地图(可缩放)           │ 浙江省 2/3               │
+│                                   │ ┌──────────────────────┐ │
+│      ● 中国计量大学                │ │● 中国计量大学 [开]    │ │
+│      ● 浙江工商大学桐庐校区         │ │  9 店 · 最近 10-07    │ │
+│      ○ 浙江科技学院(未启用)       │ ├──────────────────────┤ │
+│                                   │ │○ 浙江科技学院 [关]    │ │
+│                  [回到全国]        │ │  未关联运营数据        │ │
+│                                   │ └──────────────────────┘ │
+└───────────────────────────────────┴──────────────────────────┘
+```
+
+- 列表按省份分组,组头显示启用数
+- 每项显示「N 店 · 最近日期」;无关联数据显示「未关联运营数据」
+- 点击列表项 → 地图飞过去并选中
+- 详情卡提供「查看运营数据」→ 跳转「店铺运营」并预选该校(按名称匹配)
+- 宽度 <1100px 时列表移到地图下方
+
+## 8. 与现有功能的关系
+
+- 独立菜单,不改动店铺运营现有行为
+- 通过 `ops_shop.school` 名称关联,仅做**只读**展示(门店数、最近日期)
+- 不参与采集、不参与钉钉推送
+
+## 9. 测试
+
+### 9.1 单元
+
+- BD-09 → GCJ-02 转换(用 §4.1 五组值断言,误差 < 200m)
+- 名称匹配:精确 / 带括号 / 简称 / 无命中
+- 坐标范围校验(境内 / 境外)
+- 省份识别(中国计量大学 → 浙江省;境外点 → 空)
+
+### 9.2 接口(临时 SQLite)
+
+- 创建(带坐标 / 仅名称自动匹配)→ 列表可见
+- 重名 400;停用/启用;删除
+- 未定位学校(lng/lat 为空)不报错,`enabled=1` 也不点亮
+- 关联字段:造 2 条 `ops_shop` 同名数据 → 返回门店数 2 与最近日期
+- 内置项不可删除(400),可停用;运行时项可删除
+- 种子 + 运行时合并:同名时运行时覆盖
+- 文件损坏:仍能返回种子数据,不 500
+- ops 库不可用:`GET /api/schools` 仍 200,关联字段为空
+
+### 9.3 冒烟
+
+`GET /api/schools` 200(只读)
+
+### 9.4 手工验收
+
+输入「中国计量大学」→ 联想出现 → 选中 → 地图飞到杭州下沙 → 创建 → 点位点亮 → 刷新仍在 → 列表显示关联门店数与最近日期 → 停用变灰 → 删除时若有运营数据出现二次确认。
+
+## 10. 待办清单(评审通过后拆计划)
+
+- [ ] `scripts/build-university-data.js`(数据生成脚本,含 BD-09 转换与省份归属;数据文件已就绪)
+- [ ] `public/vendor/cn-universities.json`(已生成,待登记静态白名单)
+- [ ] `server/schools.seed.json`(内置浙江工商桐庐校区)
+- [ ] `schools-store.js`:种子 + 运行时文件合并、原子写、损坏兜底
+- [ ] `/api/schools` 端点(含 search / locate / 关联查询 / 删除保护)
+- [ ] `public/schools.js`:地图、联想、创建、编辑、启停、删除确认、列表联动
+- [ ] `index.html` 菜单与页面、`app.js` 注册、`style.css`
+- [ ] 测试脚本扩展 + smoke 只读断言
+- [ ] README 增补「校区地图」与坐标系说明
+
+## 11. 已确认事项
+
+1. **未收录学校**:在线地理编码兜底 → 失败允许创建为「未定位」(灰点、不点亮),列表内可重试定位
+2. **关联跳转**:详情卡提供「查看运营数据」,跳转「店铺运营」并预选该学校
+3. 不做批量导入;创建后默认启用
+4. 学校数据用 JSON(种子 + 运行时),不进数据库;内置项只能停用不能删除

+ 604 - 0
docs/superpowers/specs/2026-10-20-manual-ops-import-design.md

@@ -0,0 +1,604 @@
+# 店铺运营数据上传 · 设计文档
+
+- 日期:2026-10-20(SQLite 先行 / PG 可切换版)
+- 分支:`dev`
+- 状态:待评审
+
+## 1. 背景
+
+现有经营数据只有一个入口:从本机淘宝闪购商家客户端按天采集([napos-service.js](../../../napos-service.js)),写入本地数据湖 `data/reports/`。
+
+实际使用中出现两类「采集之外」的数据:
+
+| 文件 | 形态 | 用途 |
+|---|---|---|
+| `浙江工商桐庐校区20261018至20261018_全部门店.xlsx` | 平台导出,124 列 × 10 店 × 1 天 | 与采集同源,可补齐缺失日期 |
+| `浙江工商桐庐校区20261019至20261019_全部门店.xlsx` | 同上 | 同上 |
+| `店铺运营数据.xlsx` | 人工统计,6 基础列 + 29 天 × 4 指标,一行一店 | 跨校区运营看板 |
+
+本文只覆盖**第三类:人工统计表**。前两类(平台导出)作为后续解析器接入同一套框架,不在本次范围。
+
+### 为什么不能直接并进数据湖
+
+`data/reports/` 的组织维度是「主体 = 商家账号(merchantId)」,行键 `date|scope|chainId|shopId`,全公司/连锁行由采集时单独取回。人工统计表有三个结构性冲突:
+
+1. **跨主体**:一份文件含 3 个学校(浙江科技学院 17 店、杭州师范大学下沙校区 15 店、浙江工商大学桐庐校区 9 店,共 36 家店,其中 34 家有 ID),不属于任何单一 merchantId
+2. **指标少**:只有订单量、营收、曝光量、进店转化率 4 项,另 100+ 列在采集中存在而此处缺失
+3. **口径人工**:数值由人从后台抄录,存在格式错误,可信度低于采集值
+
+因此人工统计表**独立存储、独立展示**,不与采集数据合并。两者通过 `shopId + date` 可关联,为将来的「采集 vs 人工」差异视图留路。
+
+### 存储位置
+
+数据进关系库,但**驱动可切换**:
+
+- **本期(测试阶段)**:本地 SQLite(`data/ops.db`),用 Node 22 内置 `node:sqlite`,不引入依赖,整个流程离线可跑通
+- **上线阶段**:云端 PostgreSQL,同一套表和接口,只换驱动和连接串
+
+存储层抽象成统一接口(见 §5),两个实现互换,解析器与界面完全不知道下面是哪种库。
+
+## 2. 目标与非目标
+
+### 目标
+
+- 上传 `店铺运营数据.xlsx`,**原件永久保留**(字节级不变,可下载回)
+- 解析为规范行,写入关系库,可按店铺 / 学校 / 日期 / 指标查询
+- 界面以「报表矩阵」或「卡片」展示,指标由用户勾选,默认展示人工表的 4 个字段
+- 解析问题不丢数据:失败的单元格保留原文并标记,可回看
+- 支持对原件**重新解析**(解析规则迭代后不需要重新上传)
+- 本地 SQLite 跑通完整流程;切换云端 PG 只改配置,不改业务代码
+- 数据库不可用时服务照常启动,仅「店铺运营」功能不可用
+
+### 非目标
+
+- 不做平台导出(124 列)的导入 —— 后续在同一框架上加解析器
+- 不做采集数据与人工数据的合并报表或差异对照
+- 不接入智能问数、趋势图表、钉钉 AI 表格推送
+- 不支持 `.xls`、`.csv`、在线编辑单元格、多文件一次上传
+- 不做多租户隔离(见 §13),本期单租户
+
+## 3. 数据形态(实测)
+
+`店铺运营数据.xlsx`:
+
+- `Sheet1` 尺寸 `A1:DR43`(122 列 × 43 行),`Sheet2` 为空
+- 表头一行:`序号 | 序号 | 学校名称 | 店铺名称 | ID | 上线状态 | 9.9订单量 | 9.9营收 | 9.9曝光量 | 9.9进店转换率 | …`,前两列都叫「序号」,第 1 列实际为空
+- 42 个数据行:36 行有店铺名,其中 34 行有店铺 ID、2 行无 ID(未上线);另 6 行是占位行(有学校名、无店铺名)
+- 日期列 29 组:`9.9` ~ `10.7`,每组 4 列(订单量 / 营收 / 曝光量 / 进店转换率),共 116 列
+- 上线状态分布(36 家):已上线 28、暂停营业 6、未上线 2
+- 解析后应有 **36 × 29 = 1044 行**
+
+脏数据(真实存在,必须处理):
+
+| 类型 | 实例 | 数量 |
+|---|---|---|
+| 转化率写成百分数字符串 | `10..6%` | 1 |
+| 转化率写成百分数数值 | `12.3`、`4.9`(应为 0.123 / 0.049) | 2 |
+| 非数值文本 | `未开放` | 1 |
+| 转化率空白 | `None` | 179 |
+
+## 4. 架构
+
+```
+浏览器
+  │  POST /api/ops/upload  { fileName, contentBase64, year }
+  ▼
+ops-import.js        格式识别 → 解析器 → 行级清洗 → 警告收集
+  │
+  ▼
+ops-store.js         统一存储接口(事务写入 / 查询 / 原件读取)
+  │
+  ├── stores/ops-sqlite.js   node:sqlite → data/ops.db        ← 本期默认
+  └── stores/ops-pg.js       pg → 云端 PostgreSQL             ← 上线切换
+  ▼
+GET /api/ops/rows    筛选/矩阵查询
+  ▼
+public/ops.js        报表矩阵 / 卡片视图
+```
+
+模块划分(每个模块单一职责,可独立测试):
+
+| 文件 | 职责 | 依赖 |
+|---|---|---|
+| `server/services/xlsx.js` | 读 xlsx:`Buffer → { sheets: [{ name, rows: Cell[][] }] }` | Node 内置 `zlib` |
+| `ops-import.js` | 解析器注册表 + 人工统计表解析器(识别、宽表转长表、清洗) | `xlsx.js` |
+| `ops-store.js` | 驱动选择、统一接口、迁移、脱敏日志 | 两个 driver |
+| `server/stores/ops-sqlite.js` | SQLite 实现(`node:sqlite`) | 内置模块 |
+| `server/stores/ops-pg.js` | PostgreSQL 实现(`pg`,本期不装) | `pg` |
+| `server/routes/ops.js` | HTTP 端点 + 数据库不可用时的降级响应 | 上面几个 + `util.js` |
+| `public/ops.js` | 页面交互与渲染 | 无(原生 JS) |
+
+解析器以注册表组织(`[{ kind, detect(header), parse(cells, opts) }]`),后续加平台导出格式时只追加一项,不动存储与界面。
+
+## 5. 存储层
+
+### 5.1 配置
+
+`config.json`(模板已加好):
+
+```json
+"storage": {
+  "driver": "sqlite",
+  "sqlite": { "file": "data/ops.db" },
+  "postgres": {
+    "connectionString": "",
+    "ssl": true,
+    "max": 5,
+    "schema": "public",
+    "connectTimeoutMs": 10000
+  }
+}
+```
+
+- `driver`:`sqlite`(默认)| `postgres`
+- `driver=sqlite`:文件不存在时自动创建;`data/` 已 gitignore
+- `driver=postgres` 且 `connectionString` 为空 → ops 功能禁用(服务不受影响)
+- 环境变量 `OPS_DATABASE_URL` 可覆盖 PG 连接串(部署与测试用)
+- `node:sqlite` 需要 Node ≥ 22.5,缺失时 ops 禁用并返回明确原因(其它功能不受影响)
+
+### 5.2 统一接口
+
+```js
+// ops-store.js 对外暴露
+createStore(config) → {
+  driver,                     // 'sqlite' | 'postgres'
+  init(),                     // 建表 + schema_version(幂等,进程内只跑一次)
+  health(),                   // { ok, driver, detail }
+  withTransaction(fn),        // 事务包装,失败自动回滚
+  insertUpload(tx, upload),   // 含 content BLOB/bytea
+  upsertRows(tx, rows),       // ON CONFLICT (date, shop_key) DO UPDATE
+  upsertShops(tx, shops),
+  findUploadBySha(sha256),
+  listUploads(),
+  getUpload(id),              // 不含 content
+  getContent(id),             // 只取原件字节
+  updateReport(id, report),
+  queryRows({ from, to, schools, shops }) → rows,
+  summary(),                  // 学校/店铺/覆盖区间/计数
+}
+```
+
+SQL 只在两个 driver 内部出现;`ops-import.js` 与路由层不感知方言。
+
+### 5.3 表结构
+
+SQLite 版(主):
+
+```sql
+CREATE TABLE IF NOT EXISTS ops_meta (
+  key TEXT PRIMARY KEY, value TEXT NOT NULL
+);  -- schema_version = '1'
+
+CREATE TABLE IF NOT EXISTS ops_upload (
+  id             TEXT PRIMARY KEY,        -- YYYYMMDD-HHmmss-xxxxxx(服务端生成)
+  original_name  TEXT NOT NULL,
+  sha256         TEXT NOT NULL,
+  size_bytes     INTEGER NOT NULL,
+  uploaded_at    TEXT NOT NULL,           -- ISO 8601
+  year           INTEGER NOT NULL,
+  parser_kind    TEXT NOT NULL,
+  parser_version INTEGER NOT NULL,
+  report         TEXT NOT NULL DEFAULT '{}',   -- JSON 文本
+  content        BLOB NOT NULL                 -- 原件
+);
+CREATE INDEX IF NOT EXISTS ops_upload_sha256_idx ON ops_upload (sha256, uploaded_at DESC);
+
+CREATE TABLE IF NOT EXISTS ops_shop (
+  key            TEXT PRIMARY KEY,        -- 有 ID 用 ID,否则 school|shopName
+  shop_id        TEXT,
+  shop_name      TEXT NOT NULL,
+  school         TEXT NOT NULL,
+  status         TEXT,
+  last_upload_id TEXT REFERENCES ops_upload(id) ON DELETE SET NULL,
+  first_seen     TEXT NOT NULL,
+  last_seen      TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS ops_row (
+  date       TEXT NOT NULL,               -- YYYYMMDD
+  shop_key   TEXT NOT NULL,
+  upload_id  TEXT NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
+  shop_id    TEXT,
+  shop_name  TEXT NOT NULL,
+  school     TEXT NOT NULL,
+  status     TEXT,
+  order_cnt  INTEGER,
+  revenue    REAL,
+  exposure   INTEGER,
+  click_rate REAL,
+  raw        TEXT NOT NULL DEFAULT '{}',  -- JSON 文本
+  quality    TEXT NOT NULL DEFAULT '[]',  -- JSON 数组
+  updated_at TEXT NOT NULL,
+  PRIMARY KEY (date, shop_key)
+);
+CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date);
+```
+
+PG 版差异(同一接口,方言差异只在这几处):
+
+| SQLite | PostgreSQL |
+|---|---|
+| `BLOB` | `bytea` |
+| `TEXT` 存时间 | `timestamptz` |
+| `REAL` | `numeric(14,2)` / `numeric(8,6)` |
+| JSON 文本 | `jsonb` |
+| `quality` JSON 文本 | `text[]` |
+
+SQLite 连接参数:`journal_mode=WAL`、`busy_timeout=5000`、`foreign_keys=ON`。
+
+### 5.4 写入流程(单事务)
+
+1. `BEGIN`
+2. `INSERT INTO ops_upload (...)`
+3. 批量 `INSERT INTO ops_row ... ON CONFLICT (date, shop_key) DO UPDATE SET ...`(每批 200 行)
+4. `INSERT INTO ops_shop ... ON CONFLICT (key) DO UPDATE SET ...`
+5. `COMMIT`(失败则 `ROLLBACK`,本次上传不留残留)
+
+### 5.5 覆盖与版本
+
+- 同日同店再次导入 → `ON CONFLICT DO UPDATE` 覆盖 4 个指标、`raw`、`upload_id`,返回 `overwritten` 计数
+- 每次上传的 `ops_upload` 行都保留(含原件),历史可追溯
+- 重新解析:读 `content` → 重新生成行 → 同一事务内 upsert → 更新 `report`;`id` 与 `content` 不变
+
+### 5.6 从 SQLite 切到 PG
+
+1. `config.json` 改 `driver: "postgres"` 并填 `connectionString`
+2. `npm run db:init` 建表
+3. 需要历史数据时,用 `npm run db:export`(SQLite → JSON)与 `db:import`(JSON → PG)搬一次;不需要则直接空库开始
+
+两个脚本在实施阶段一并提供(小工具,复用现有接口)。
+
+## 6. 解析规则
+
+### 6.1 格式识别
+
+表头同时出现下列两类以上即判定为人工统计表:
+
+- 含「订单量」「营收」「曝光量」字样
+- 含「转换率」(人工表用「转换」,平台用「转化」,两者都接受)
+
+识别失败 → 400,返回支持的格式说明,不落库。
+
+### 6.2 日期
+
+- 列名 `9.9订单量` → 月 9、日 9
+- 年份取上传时传入的 `year`(弹窗默认当前年),落库为 `YYYYMMDD`
+- 跨年判断:按列出现顺序,出现「月份比前一列小 6 以上」时年份 +1(如 `12.31` 之后出现 `1.1`)
+- 月或日超出 1–12 / 1–31 → 跳过该列并记 warning
+
+### 6.3 列定位
+
+按表头名称定位,不依赖列序号:
+
+- `学校名称`、`店铺名称`、`ID`、`上线状态` 按精确名匹配,容忍前后空格
+- 同日 4 列按「日期 + 指标名」组合定位,顺序无关
+- 缺失必需列(店铺名称)→ 识别失败
+- 重复「序号」列、首列空白不影响
+
+### 6.4 值清洗
+
+指标内部键与采集口径对齐,便于将来对照:
+
+| 人工表列 | 内部键 | 采集对应字段 | 类型 |
+|---|---|---|---|
+| 订单量 | `order_cnt` | `validOrderCnt` | 整数 |
+| 营收 | `revenue` | `grossAmt` | 数值(元) |
+| 曝光量 | `exposure` | `expUv` | 整数 |
+| 进店转换率 | `click_rate` | `clkExpRate` | 比率 0–1 |
+
+清洗顺序(对每个单元格):
+
+1. `null` / 空串 → `NULL` + `missing`
+2. 字符串去空白;含 `%` 或 `%` → 去掉符号,标记 `pct-symbol`
+3. 折叠重复小数点(`10..6` → `10.6`)→ 标记 `malformed-percent`
+4. `Number()` 解析;失败且非空(`未开放` 等)→ `NULL` + `non-numeric`,原文进 `raw`
+5. 比率指标且数值 > 1 → 除以 100,标记 `percent-scaled`
+6. 比率结果 > 1 或 < 0 → 保留并标记 `out-of-range`
+7. 数量类指标为负 → 置 0 并标记 `negative`
+
+### 6.5 行过滤
+
+跳过(不产生 warning,属正常结构):
+
+- 店铺名称为空(含 6 个占位行)
+- 该店在所有日期上都没有任何一个数值(整行空店)
+
+有数据的店保留全部日期,当天四项皆空也留一行(值全为 `NULL`),矩阵显示「–」,
+保证「店 × 天」结构完整(36 × 29 = 1044 行)。
+
+保留但标记:
+
+- 店铺 ID 为空但店铺名存在 → 用 `school|shopName` 作 `shop_key`,`quality` 加 `no-shop-id`
+- 状态为空 → 保留 `NULL`
+
+### 6.6 解析器实现(零依赖)
+
+`server/services/xlsx.js` 只做一件事:把 xlsx 解码成二维单元格数组。
+
+xlsx 是 ZIP 容器,实现路径:
+
+1. 解析 ZIP 中央目录,取 `xl/workbook.xml`、`xl/worksheets/sheet1.xml`、`xl/sharedStrings.xml`
+2. `zlib.inflateRawSync` 解压(`store` 方式直接切片)
+3. 正则提取 `<row>` / `<c>` / `<v>` / `<is><t>`;类型 `s` 查共享字符串表,无 `<v>` 视为空
+4. 合并单元格按左上角取值,其余为空(本格式用不到合并)
+5. 日期单元格若为 Excel 序列号且列名疑似日期,按 `1899-12-30` 基准换算(本格式日期是字符串,不触发)
+
+不做:公式计算(读缓存值)、样式、多 sheet 遍历(只读第一个非空 sheet)、`.xls` 二进制。
+
+解析异常 → 400「文件可能不是标准 xlsx」,不入库。
+
+## 7. 接口
+
+所有端点挂在 `server/routes/ops.js`,现有 `originGuard` / `tokenGuard` 自动生效。
+
+### 7.1 上传
+
+`POST /api/ops/upload`
+
+```json
+{ "fileName": "店铺运营数据.xlsx", "contentBase64": "UEsDBBQ…", "year": 2026, "force": false }
+```
+
+- `contentBase64`:文件内容 base64(接受去掉 `data:...;base64,` 前缀)
+- `year`:缺省当前年
+- `force=false` 且 sha256 已存在 → 409 + 已存在的 `uploadId`
+
+成功 200:
+
+```json
+{
+  "ok": true,
+  "uploadId": "20261020-153012-a1b2c3",
+  "report": {
+    "rows": 1044, "shops": 36, "overwritten": 0, "warningCount": 183,
+    "dates": { "from": "20260909", "to": "20261007" }
+  },
+  "warnings": [
+    { "row": 4, "column": "9.14进店转换率", "raw": "10..6%", "reason": "malformed-percent" }
+  ]
+}
+```
+
+`warnings` 最多返回前 200 条,全量在 `report` 里;`warningCount` 是全量计数。
+
+失败码:400(格式/内容不合法)、409(重复)、413(>10MB)、503(存储不可用)、500(写入失败)。
+
+### 7.2 概览
+
+`GET /api/ops/summary`
+
+```json
+{
+  "enabled": true,
+  "driver": "sqlite",
+  "schools": [{ "name": "浙江工商大学桐庐校区", "shopCount": 9 }],
+  "shops": [{ "key": "1362222034", "shopName": "…", "school": "…", "status": "已上线" }],
+  "coverage": { "from": "20260909", "to": "20261007" },
+  "uploadCount": 1,
+  "rowCount": 1044,
+  "lastUpload": { "id": "…", "at": "…", "originalName": "…", "rows": 1044, "warningCount": 183 }
+}
+```
+
+- 存储未配置/不可用:200 + `{ "enabled": false, "reason": "storage-not-configured" | "storage-unreachable", "detail": "脱敏信息" }`(页面据此渲染引导态)
+- 空库:200,数组为空、`coverage: null`
+
+### 7.3 矩阵查询
+
+`GET /api/ops/rows?from=20260909&to=20261007&schools=浙江科技学院&shops=1362222034&metrics=order_cnt,revenue`
+
+- `from` / `to`:`YYYYMMDD`,缺省取全区间
+- `schools` / `shops`:逗号分隔,缺省全选
+- `metrics`:缺省 4 项全量
+
+响应:
+
+```json
+{
+  "from": "20260909", "to": "20261007",
+  "dates": ["20260909", "…"],
+  "metrics": ["order_cnt", "revenue", "exposure", "click_rate"],
+  "groups": [
+    {
+      "school": "浙江工商大学桐庐校区",
+      "shops": [
+        {
+          "key": "1362222034", "shopName": "…", "status": "已上线",
+          "series": { "order_cnt": { "20260909": 0, "20260910": 5 }, "revenue": { "…": 58.1 } },
+          "quality": { "20260914": ["percent-scaled"] },
+          "totals": { "order_cnt": 1234, "revenue": 23456.7 }
+        }
+      ]
+    }
+  ]
+}
+```
+
+`totals` 只对可加指标(订单量、营收、曝光量)计算;比率由前端按权重算。
+
+### 7.4 导入记录与原件
+
+- `GET /api/ops/uploads` → `{ uploads: [{ id, originalName, uploadedAt, rows, shops, warningCount, parserKind, sizeBytes }] }`,按时间倒序,**不回传 `content`**
+- `GET /api/ops/uploads/:id/file` → 下载原件:`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,`Content-Disposition: attachment; filename*=UTF-8''<encoded>`
+  - `id` 必须匹配 `^\d{8}-\d{6}-[a-z0-9]{6}$`,否则 400
+
+### 7.5 重新解析
+
+`POST /api/ops/uploads/:id/reparse` → 读原件 → 重新解析 → 事务内覆盖该上传产生的行 → 更新 `report`,返回新报告。`id` 与原文件不变。
+
+### 7.6 导出
+
+`GET /api/ops/export?…`(参数同 `rows`),CSV、UTF-8 BOM:
+
+- 只勾一个指标 → 矩阵格式(行=店铺、列=日期)
+- 勾多个指标 → 长表堆叠(`学校,店铺,日期,指标,数值`),便于透视
+
+### 7.7 不提供的端点
+
+- 删除上传:本期不做(`ON DELETE CASCADE` 已为将来留好)
+- 编辑单元格:不做,人工表以原件为准
+
+## 8. 界面
+
+### 8.1 入口
+
+侧边栏「经营分析」组新增一级菜单 `店铺运营`,位于「数据采集」之后、「数据中台」之前。视图标识 `ops`,沿用现有 `data-view` 机制。
+
+### 8.2 工具条
+
+```
+[⬆ 上传文件]  学校 ▾(全选)  日期 [09-09]~[10-07]  指标 ☑订单量 ☑营收 ☑曝光量 ☑进店转化率  [报表|卡片]  [导出 CSV]
+```
+
+- 学校、指标多选,交互复用现有 `scan-dropdown-menu` 组件样式
+- 指标勾选存 `localStorage`(`ops.metrics`),默认 4 项全选
+- 视图切换存 `localStorage`(`ops.view`),默认「报表」
+
+### 8.3 报表视图
+
+每个勾选指标渲染一个区块,区块内一张矩阵:
+
+- 表头两行:第一行学校分组(跨列),第二行日期(`9.9` 格式,与人工表一致)
+- 首列冻结显示店铺名
+- 单元格:数值;`NULL` 显示 `–`;`quality` 非空的格子右上角 `⚠`,`title` 显示原文与原因
+- 比率显示为百分比(`0.047` → `4.7%`),1 位小数
+- 金额 2 位小数 + 千分位
+- 每个学校分组末尾一行小计(数量/金额求和;比率按曝光量或订单量加权,权重为 0 时显示 `–`)
+- 日期列多时横向滚动,店铺列 `position: sticky`
+
+### 8.4 卡片视图
+
+- 每店一张卡:店名、学校、状态徽标
+- 卡内按勾选指标显示区间合计(或加权均值)+ 迷你折线(SVG,无依赖)
+- 点卡片展开逐日明细
+- 按学校分组,组头可折叠
+
+### 8.5 上传弹窗
+
+1. 拖拽区 / 选择文件(`accept=".xlsx"`)
+2. 年份输入(默认当前年),旁注"文件里的日期没有年份,请确认"
+3. 点「开始导入」→ 转 base64 调上传接口
+4. 结果区:行数 / 店铺数 / 日期范围 / 覆盖条数 / 警告列表(前 20 条 + 展开全部)
+5. 409 时提示「该文件已导入过」并提供「仍然导入」(`force=true`)
+6. 503 时提示「存储不可用」并给重试按钮
+
+不做"先预览再导入"的两步流程:解析在服务端,导入即入库,结果以报告呈现;不满意可用原件重新解析覆盖。
+
+### 8.6 导入记录
+
+右上角「导入记录」抽屉:文件名、上传时间、行数、警告数、下载原件、重新解析。
+
+### 8.7 空态与错误
+
+- 存储未配置/不可用:整页引导(SQLite 一般是文件权限问题;PG 提示填连接串)+ 重试按钮
+- 空库:上传引导,不渲染空表格
+- 查询失败:表格区显示重试
+- 所有降级都只影响本菜单,其它功能不受影响
+
+## 9. 错误处理与边界
+
+| 场景 | 行为 |
+|---|---|
+| 非 zip / 非 xlsx | 400「文件不是标准 xlsx」,不入库 |
+| 找不到数据表 | 400「未找到数据表」 |
+| 表头不匹配任何解析器 | 400「暂不支持该表格格式」 |
+| 文件 > 10MB | 413 |
+| base64 解码失败 | 400 |
+| SQLite 文件不可写 | ops 503;summary `enabled:false, reason:"storage-unreachable"` |
+| PG 未配置连接串 | ops 503;summary `enabled:false, reason:"storage-not-configured"` |
+| `node:sqlite` 不可用(Node < 22.5) | ops 503;summary 带 `reason:"sqlite-unavailable"` |
+| 写入事务失败 | 回滚,500,无半成品数据 |
+| 同一文件重复上传 | 409 + 已有 uploadId(`force=true` 继续) |
+| 行级解析错误 | 不中断,进 `warnings` |
+| 并发上传 | 每批独立事务 + `ON CONFLICT` 幂等;SQLite 用 WAL + busy_timeout |
+
+## 10. 安全
+
+- `uploadId` 服务端生成,客户端不可指定
+- 下载端点用正则校验 id 后再查库(不拼路径)
+- 原文件名去掉路径分隔符与控制字符,长度截断 120
+- 大小上限 10MB(`MAX_BODY_BYTES` 32MB,base64 后约 13.3MB,仍在限内)
+- 不解析宏、不外链、不执行单元格内容
+- 连接串只存 `config.json`(gitignore);日志与响应一律脱敏
+- SQL 全部参数化,不拼接用户输入
+- SQLite 文件权限沿用系统默认,位于项目 `data/`(已 gitignore)
+
+## 11. 测试
+
+### 11.1 解析器单测(`scripts/test-ops-import.js`,`npm run test:ops`)
+
+fixture:`scripts/fixtures/ops-manual-sample.xlsx`,**脱敏样例**(店名改 `样例店 01`,数值按原形态保留),覆盖全部边界值:
+
+| 用例 | 断言 |
+|---|---|
+| 表头识别 | 返回 `manual-ops`,29 天 × 4 指标 |
+| 日期解析 | `9.9` + year 2026 → `20260909`;`10.7` → `20261007` |
+| 脏值 `10..6%` | `click_rate=0.106`,`quality` 含 `malformed-percent` |
+| 脏值 `12.3` | `click_rate=0.123`,`quality` 含 `percent-scaled` |
+| 文本 `未开放` | `NULL`,`quality` 含 `non-numeric`,`raw` 保留原文 |
+| 空单元格 | `NULL` + `missing`,与 `0` 可区分 |
+| 占位行 | 不产生行 |
+| 无 ID 店铺 | 落库,`shop_key = school|shopName`,`quality` 含 `no-shop-id` |
+| 行数 | 36 店 × 29 天 = 1044 |
+
+### 11.2 端到端测试(同脚本,临时 SQLite,不依赖外部环境)
+
+用 `os.tmpdir()` 下的临时库文件,跑完删除;起临时服务(复用 smoke 的随机端口方式):
+
+- 空态:`GET /api/ops/summary` → 200,`enabled:true`,数组为空
+- 上传:200,返回 `uploadId`;`ops_upload.content` 字节 sha256 与请求一致
+- 重复上传:409;`force=true` → 200
+- 查询:行数/店铺数/日期范围正确;`schools`、`metrics` 过滤生效
+- 下载:字节与原文件一致
+- 非法 id:400
+- 重新解析:200,行数与 `upload_id` 不变
+- 事务性:构造写入失败 → 500,`ops_upload` 无残留
+- 降级:把库文件设为不可写 → summary `enabled:false`,其它菜单接口仍 200
+
+PG 相关用例用 `OPS_TEST_DATABASE_URL`,未设置则跳过并打印原因(本期不装 `pg`)。
+
+### 11.3 smoke 扩展
+
+`scripts/smoke.js` 增加只读断言:`GET /api/ops/summary` 200、`GET /api/ops/uploads` 200、`GET /api/ops/rows` 200。不新增写操作,保持 smoke「只读 + 无副作用」边界。
+
+### 11.4 手工验收
+
+上传真实文件 → 切指标/学校/日期 → 报表与卡片 → CSV 用 Excel 打开 → 重传同文件见 409 → 覆盖导入后数值更新且旧原件仍在记录里 → 重启服务后数据仍在。
+
+## 12. 对现有功能的影响
+
+| 功能 | 影响 | 说明 |
+|---|---|---|
+| 数据采集 / 同步 / 诊断 | 无 | 只读 `data/reports/` |
+| 报表 / 趋势图表 / 问数 | 无 | 各自读 `data/reports/`,不感知 ops 数据 |
+| 钉钉 AI 表格推送 | 无 | 推送范围仍是 lake 行 |
+| 任务中心 | 无 | 导入是同步操作,不注册任务 |
+| 服务启动 | 低风险 | 存储惰性初始化,失败不阻塞启动 |
+| Node 版本 | 有要求 | SQLite 驱动需 Node ≥ 22.5;PG 驱动不受限。README 注明 |
+| 打包分发 | 无变化 | SQLite 是本地文件,安装包不需要外部依赖 |
+| 备份 | 小改 | `data/ops.db` 随 `data/` 一起备份(README 补一句) |
+
+## 13. 后续扩展(不在本次范围)
+
+1. **切换云端 PG**:按 §5.6 操作;`pg` 依赖届时再装
+2. **多租户**:若将来分发给不同客户,三张表需加 `tenant_id` 并进主键。本期单租户
+3. **平台导出导入**:新增 `parser.kind = 'platform-export'`,124 列 → 现有 lake 行结构
+4. **采集 vs 人工对照**:用 `shopId + date` 关联,同指标两值并列,差异超阈值标红
+5. **人工数据并入问数**:作为独立数据源,避免与采集口径混淆
+6. **删除/回滚上传**:按 `uploadId` 删除(`ON DELETE CASCADE` 已就绪)
+
+## 14. 待办清单(供实施计划拆解)
+
+- [ ] `server/services/xlsx.js`:ZIP/XML 读取器
+- [ ] `ops-import.js`:解析器注册表 + `manual-ops` 解析器
+- [ ] `ops-store.js`:驱动选择 + 统一接口 + 迁移
+- [ ] `server/stores/ops-sqlite.js`:`node:sqlite` 实现
+- [ ] `server/stores/ops-pg.js`:PG 实现(懒加载 `pg`,未安装时明确报错)
+- [ ] `server/routes/ops.js`:上传/概览/查询/记录/下载/重解析/导出 + 降级
+- [ ] `server/routes/index.js` 注册 `ops`
+- [ ] `server/context.js`:读取 `storage` 配置与默认值合并
+- [ ] `public/ops.js` + `index.html` 页面与菜单 + `style.css`
+- [ ] `scripts/db-init.js` + `scripts/db-export.js` / `db-import.js`
+- [ ] `scripts/test-ops-import.js` + 脱敏 fixture
+- [ ] `scripts/smoke.js` 只读断言
+- [ ] README:「店铺运营数据导入」章节 + Node 版本说明 + 备份说明

+ 128 - 0
docs/superpowers/specs/2026-10-20-ops-cockpit-design.md

@@ -0,0 +1,128 @@
+# 经营驾驶舱 · 设计文档
+
+- 日期:2026-10-20
+- 分支:`dev`
+- 状态:待评审
+- 范围:新增独立菜单「经营驾驶舱」,面向经营总览(不含履约、营销专块)
+
+## 1. 背景
+
+「店铺运营」现在是明细表 + 卡片,适合逐店核对,但不适合一眼看经营大盘。需要一屏看清:今天做了多少、比昨天好还是差、哪家店在拖后腿、转化在哪一环掉。
+
+数据源:上传的运营明细(平台导出全字段 + 人工统计表),已按「日期 + 门店」合并去重。
+
+## 2. 首屏内容
+
+```
+经营驾驶舱                         日期 [10.18] ~ [10.19]   学校 ▾   店铺 ▾
+┌──────────┬──────────┬──────────┬──────────┬───────────┬─────────┐
+│ 营业额    │ 有效订单  │ 客单价    │ 曝光人数  │ 进店转化率 │ 店铺评分 │
+│ 2,363.73 │    86    │  27.49   │  6,775   │   8.6%    │  4.62   │
+│ ↓ 7.2%   │  ↓12.8%  │  ↑ 6.5%  │  ↓16.1%  │  ↓0.4pp   │   —     │
+└──────────┴──────────┴──────────┴──────────┴───────────┴─────────┘
+┌────────────────────────────┬────────────────────────────┐
+│ 营业额 / 订单趋势(双轴)    │ 门店营业额 Top 10(横向条形) │
+├────────────────────────────┼────────────────────────────┤
+│ 转化漏斗 曝光→进店→下单      │ 收入结构:收入 / 支出 / 补贴  │
+└────────────────────────────┴────────────────────────────┘
+```
+
+## 3. 指标口径(实现必须统一,不允许前端各算各的)
+
+| 指标 | 口径 | 说明 |
+|---|---|---|
+| 营业额 | Σ 营业额 | 平台列「营业额」;人工表映射为 `revenue` |
+| 有效订单 | Σ 有效订单 | 整数 |
+| 客单价 | 营业额 ÷ 有效订单 | 订单为 0 时显示 `–` |
+| 曝光人数 | Σ 曝光人数 | |
+| 进店转化率 | Σ 进店人数 ÷ Σ 曝光人数 | **不是**各店转化率的算术平均 |
+| 店铺评分 | 评分 > 0 的门店取均值 | 未开业/无评分(0)不参与 |
+
+**环比**:所选区间 vs **紧邻的前一个等长区间**。
+
+- 金额/数量:(本期 − 上期) ÷ 上期 × 100%
+- 比率:本期 − 上期,单位 **pp**(百分点)
+- 上期为 0 或无数据:显示 `—`,不显示 ∞
+- 评分不做环比(评分是存量指标,短期对比意义不大)
+
+## 4. 四张图
+
+| 图 | 形式 | 数据 |
+|---|---|---|
+| 趋势 | 柱(营业额,左轴)+ 线(订单,右轴) | 按日聚合 |
+| 门店排行 | 横向条形 Top 10 | 所选区间营业额,带数值标签 |
+| 转化漏斗 | 漏斗图 | 曝光人数 → 进店人数 → 下单人数,标注各层转化率 |
+| 收入结构 | 堆叠柱 / 瀑布 | 收入、支出、活动补贴、商家活动成本(按日) |
+
+配色沿用现有主题:主色 `#ff6b35`,辅助灰 `#b8b2ad`,ECharts 已在仓库内。
+
+## 5. 交互
+
+- 顶部筛选:日期区间、学校多选、店铺多选(复用店铺运营的多选组件与状态)
+- 任一筛选变化 → 重新拉取聚合并重绘,KPI 与四张图同步
+- 图表点击:
+  - 门店排行点击某店 → 跳转「店铺运营」并预选该店(可选,二期)
+- 空态:无数据时显示「所选范围没有数据」+ 清空筛选按钮
+- 加载态:KPI 与图表显示骨架/占位,避免闪烁
+
+## 6. 接口
+
+新增 **`GET /api/ops/dashboard`**(服务端聚合,保证口径一致):
+
+```
+?from=20261018&to=20261019&schools=浙江工商大学桐庐校区&shops=537278859
+```
+
+响应:
+
+```json
+{
+  "from": "20261018", "to": "20261019",
+  "prev": { "from": "20261016", "to": "20261017" },
+  "kpis": {
+    "revenue":    { "value": 2363.73, "prev": 2544.1, "delta": -7.1, "unit": "money" },
+    "orders":     { "value": 86, "prev": 99, "delta": -13.1, "unit": "int" },
+    "avgTicket":  { "value": 27.49, "prev": 25.7, "delta": 7.0, "unit": "money" },
+    "exposure":   { "value": 6775, "prev": 8090, "delta": -16.3, "unit": "int" },
+    "conversion": { "value": 0.086, "prev": 0.09, "delta": -0.4, "unit": "pp" },
+    "rating":     { "value": 4.62, "prev": null, "delta": null, "unit": "num" }
+  },
+  "trend": [{ "date": "20261018", "revenue": 2363.73, "orders": 86 }],
+  "ranking": [{ "shopId": "...", "shopName": "...", "revenue": 750.7 }],
+  "funnel": { "exposure": 6775, "visit": 585, "order": 88 },
+  "structure": [{ "date": "20261018", "income": 2363.73, "expense": 747.46, "subsidy": 51.7 }]
+}
+```
+
+实现复用 `loadMergedRows()`(人工 + 平台合并),不动现有表结构。
+
+## 7. 页面
+
+- 菜单:经营分析组,新增「经营驾驶舱」,放在「店铺运营」之前
+- 视图标识 `cockpit`,`public/cockpit.js`,复用 `ops-multi` 组件样式
+- 图表用 ECharts(已有),自适应容器宽度,窗口 resize 重绘
+
+## 8. 边界
+
+| 场景 | 表现 |
+|---|---|
+| 无数据 | 空态提示 + 清空筛选 |
+| 只有 1 天数据 | 趋势图只有一根柱;环比显示 `—`(没有上期) |
+| 上期为 0 | 环比 `—`,不显示 ∞ |
+| 存储不可用 | 显示「数据暂不可用」+ 重试 |
+| 店铺/学校筛选为空 | 与店铺运营一致:提示至少选一个 |
+
+## 9. 测试
+
+- 单测:KPI 聚合(含 0 订单客单价、评分排除 0、转化率加权)、环比计算(正负、上期 0、无上期)
+- 接口:`/api/ops/dashboard` 空库 / 有数据 / 筛选组合
+- 冒烟:只读断言 `GET /api/ops/dashboard` 200
+- 手测:切日期、切学校、切店铺,KPI 与图表同步;窗口缩放不溢出
+
+## 10. 待办(评审通过后实施)
+
+- [ ] `GET /api/ops/dashboard` 聚合实现(复用 loadMergedRows)
+- [ ] 前端 `public/cockpit.js`:筛选、KPI 卡、趋势、排行、漏斗、结构图
+- [ ] `index.html` 菜单与视图骨架、`app.js` 注册、`style.css`
+- [ ] 测试脚本扩展 + smoke 断言
+- [ ] README 增补

+ 416 - 0
ops-import.js

@@ -0,0 +1,416 @@
+'use strict';
+
+/**
+ * 店铺运营数据 · 解析层
+ * ----------------------------------------------------------------------------
+ * 把上传的 xlsx 识别成「人工统计横表」并转成规范行:
+ *   一行一店 × 29 天 × 4 指标 → 长表(每店每天一行)
+ *
+ * 解析器以注册表组织:以后加「平台导出 124 列」只需追加一个 { kind, detect, parse },
+ * 存储与界面不用改。
+ */
+
+const { readXlsx } = require('./server/services/xlsx.js');
+
+const METRICS = {
+  order_cnt: { label: '订单量', kind: 'int' },
+  revenue: { label: '营收', kind: 'money' },
+  exposure: { label: '曝光量', kind: 'int' },
+  click_rate: { label: '进店转换率', kind: 'rate' },
+};
+const METRIC_KEYS = Object.keys(METRICS);
+
+// 表头里可能出现的指标写法(人工表用「转换率」,平台用「转化率」)
+const METRIC_BY_LABEL = {
+  '订单量': 'order_cnt',
+  '营收': 'revenue',
+  '营业额': 'revenue',
+  '曝光量': 'exposure',
+  '进店转换率': 'click_rate',
+  '进店转化率': 'click_rate',
+  '转换率': 'click_rate',
+  '转化率': 'click_rate',
+};
+
+const BASE_COLUMNS = {
+  school: ['学校名称', '学校', '校区'],
+  shopName: ['店铺名称', '门店名称', '店铺', '门店'],
+  shopId: ['ID', '门店ID', '店铺ID'],
+  status: ['上线状态', '状态'],
+};
+
+const DATE_COLUMN_RE = /^(\d{1,2})\.(\d{1,2})(订单量|营收|营业额|曝光量|进店转换率|进店转化率|转换率|转化率)$/;
+
+function parseError(message, code = 'OPS_PARSE_FAILED') {
+  const err = new Error(message);
+  err.code = code;
+  return err;
+}
+
+function normalizeHeader(value) {
+  return String(value == null ? '' : value).replace(/[\s\u3000]+/g, '').trim();
+}
+
+function findColumn(header, names) {
+  for (let i = 0; i < header.length; i++) {
+    const text = normalizeHeader(header[i]);
+    if (names.includes(text)) return i;
+  }
+  // 兜底:包含匹配(例如「门店名称(必填)」)
+  for (let i = 0; i < header.length; i++) {
+    const text = normalizeHeader(header[i]);
+    if (names.some((n) => text.includes(n))) return i;
+  }
+  return -1;
+}
+
+function pad2(n) {
+  return String(n).padStart(2, '0');
+}
+
+function toShopId(raw) {
+  if (raw == null || raw === '') return '';
+  if (typeof raw === 'number') return Number.isInteger(raw) ? String(raw) : String(raw).replace(/\.0+$/, '');
+  return String(raw).trim();
+}
+
+// ---------- 值清洗 ----------
+
+/**
+ * 单元格 → 数值 + 质量标记
+ * @returns {{ value: number|null, reasons: string[], rawText: string }}
+ */
+function cleanValue(raw, metricKey) {
+  const kind = METRICS[metricKey].kind;
+  if (raw === null || raw === undefined || raw === '') {
+    return { value: null, reasons: ['missing'], rawText: '' };
+  }
+  const reasons = [];
+  let text = raw;
+  if (typeof text === 'string') {
+    text = text.trim();
+    if (text === '') return { value: null, reasons: ['missing'], rawText: String(raw) };
+    if (/[%%]/.test(text)) {
+      text = text.replace(/[%%]/g, '');
+      reasons.push('pct-symbol');
+    }
+    if (/\d\.\.\d/.test(text)) {
+      text = text.replace(/\.{2,}/g, '.');
+      reasons.push('malformed-percent');
+    }
+  }
+  const num = typeof text === 'number' ? text : Number(text);
+  if (!Number.isFinite(num)) {
+    return { value: null, reasons: [...reasons, 'non-numeric'], rawText: String(raw) };
+  }
+
+  if (kind === 'rate') {
+    const percentLike = reasons.includes('pct-symbol') || reasons.includes('malformed-percent');
+    let value = num;
+    if (percentLike) {
+      value = num / 100;
+    } else if (value > 1) {
+      value = value / 100;
+      reasons.push('percent-scaled');
+    }
+    if (value > 1 || value < 0) reasons.push('out-of-range');
+    return { value: Math.round(value * 1e6) / 1e6, reasons, rawText: String(raw) };
+  }
+
+  if (kind === 'int') {
+    let value = Math.round(num);
+    if (value < 0) {
+      value = 0;
+      reasons.push('negative');
+    }
+    return { value, reasons, rawText: String(raw) };
+  }
+
+  // money
+  if (num < 0) reasons.push('negative');
+  return { value: Math.round(num * 100) / 100, reasons, rawText: String(raw) };
+}
+
+// ---------- 表头 ----------
+
+function detect(cells) {
+  const header = (cells && cells[0]) || [];
+  const texts = header.map(normalizeHeader);
+  const metricHits = ['订单量', '营收', '营业额', '曝光量'].filter((label) => texts.some((t) => t.includes(label))).length;
+  const hasRate = texts.some((t) => t.includes('转换率') || t.includes('转化率'));
+  const hasDateCol = texts.some((t) => DATE_COLUMN_RE.test(t));
+  return metricHits >= 2 && hasRate && hasDateCol ? 'manual-ops' : null;
+}
+
+/** 平台导出(全字段明细):一行一店一天,含「日期 / 门店编号 / 门店名称」+ 100 多列指标 */
+function detectPlatformExport(cells) {
+  const header = ((cells && cells[0]) || []).map(normalizeHeader);
+  if (header.length < 50) return false;
+  const has = (name) => header.some((h) => h === name);
+  return has('日期') && has('门店编号') && has('门店名称');
+}
+
+/** 日期归一化:2026-10-18 / 2026/10/18 / 20261018 → 20261018 */
+function normalizeDate(value) {
+  const text = String(value == null ? '' : value).trim();
+  const m = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/.exec(text);
+  if (m) return m[1] + pad2(Number(m[2])) + pad2(Number(m[3]));
+  const compact = text.replace(/\D/g, '');
+  return /^\d{8}$/.test(compact) ? compact : '';
+}
+
+function indexOfHeader(header, name) {
+  for (let i = 0; i < header.length; i++) if (normalizeHeader(header[i]) === name) return i;
+  return -1;
+}
+
+/**
+ * 平台导出 → 全字段行
+ * @param {Array<Array<any>>} cells
+ * @param {{ school?: string }} options
+ */
+function parsePlatformExport(cells, options = {}) {
+  const header = ((cells && cells[0]) || []).map((h) => String(h == null ? '' : h).trim());
+  const iDate = indexOfHeader(header, '日期');
+  const iShopId = indexOfHeader(header, '门店编号');
+  const iShopName = indexOfHeader(header, '门店名称');
+  if (iDate < 0 || iShopId < 0 || iShopName < 0) {
+    throw parseError('缺少「日期 / 门店编号 / 门店名称」列', 'OPS_FORMAT_UNSUPPORTED');
+  }
+  const school = String(options.school || '').trim();
+  const columns = header.map((h, i) => ({ name: h, col: i })).filter((c) => c.name);
+
+  const rows = [];
+  const shops = new Map();
+  const dates = new Set();
+  const warnings = [];
+
+  for (let r = 1; r < cells.length; r++) {
+    const line = cells[r] || [];
+    const date = normalizeDate(line[iDate]);
+    const shopId = toShopId(line[iShopId]);
+    const shopName = normalizeHeader(line[iShopName]);
+    if (!date || !shopId) {
+      const anyValue = line.some((v, i) => i !== iDate && v != null && v !== '');
+      if (anyValue) warnings.push({ row: r + 1, column: date ? '门店编号' : '日期', raw: date ? shopId : String(line[iDate] || ''), reason: 'missing-key' });
+      continue;
+    }
+    if (!shopName) {
+      warnings.push({ row: r + 1, column: '门店名称', raw: '', reason: 'missing-shop-name' });
+      continue;
+    }
+
+    const data = {};
+    for (const c of columns) {
+      const value = line[c.col];
+      data[c.name] = value === undefined ? null : value;
+    }
+
+    rows.push({ date, shopId, shopName, school, data });
+    dates.add(date);
+    if (!shops.has(shopId)) shops.set(shopId, { key: shopId, shopId, shopName, school, status: '', kind: 'platform' });
+  }
+
+  const columnNames = columns.map((c) => c.name).filter((n) => !['日期', '门店编号', '门店名称'].includes(n));
+  return { rows, shops: [...shops.values()], warnings, dates: [...dates].sort(), kind: 'platform-export', columns: columnNames };
+}
+
+/**
+ * 日期列 → [{ col, date, metricKey }]
+ * 年份由调用方给定;列按出现顺序递增,遇到「月份比前一列小 6 以上」视为跨年。
+ */
+function parseDateColumns(header, year) {
+  const columns = [];
+  let currentYear = Number(year);
+  let prevMonth = null;
+  for (let i = 0; i < header.length; i++) {
+    const text = normalizeHeader(header[i]);
+    const m = DATE_COLUMN_RE.exec(text);
+    if (!m) continue;
+    const month = Number(m[1]);
+    const day = Number(m[2]);
+    if (month < 1 || month > 12 || day < 1 || day > 31) continue;
+    if (prevMonth !== null && month < prevMonth - 6) currentYear += 1;
+    prevMonth = month;
+    const metricKey = METRIC_BY_LABEL[m[3]];
+    if (!metricKey) continue;
+    columns.push({
+      col: i,
+      date: `${currentYear}${pad2(month)}${pad2(day)}`,
+      metricKey,
+      label: normalizeHeader(header[i]),
+    });
+  }
+  return columns;
+}
+
+// ---------- 解析 ----------
+
+/**
+ * 人工统计横表 → 长表行
+ * @param {Array<Array<any>>} cells
+ * @param {{ year?: number, school?: string }} options  school 非空时覆盖表格里的「学校名称」列
+ * @returns {{ rows: Array<object>, shops: Array<object>, warnings: Array<object>, dates: string[] }}
+ */
+function parseManualOps(cells, options = {}) {
+  const year = Number(options.year) || new Date().getFullYear();
+  const schoolOverride = String(options.school || '').trim();
+  const header = (cells && cells[0]) || [];
+  if (!header.length) throw parseError('表格为空');
+  if (detect(cells) !== 'manual-ops') throw parseError('表头不符合人工统计表格式', 'OPS_FORMAT_UNSUPPORTED');
+
+  const cols = {
+    school: findColumn(header, BASE_COLUMNS.school),
+    shopName: findColumn(header, BASE_COLUMNS.shopName),
+    shopId: findColumn(header, BASE_COLUMNS.shopId),
+    status: findColumn(header, BASE_COLUMNS.status),
+  };
+  if (cols.shopName < 0) throw parseError('缺少「店铺名称」列', 'OPS_FORMAT_UNSUPPORTED');
+
+  const dateCols = parseDateColumns(header, year);
+  if (!dateCols.length) throw parseError('未找到日期列(形如 9.9订单量)', 'OPS_FORMAT_UNSUPPORTED');
+
+  const rows = [];
+  const warnings = [];
+  const shops = new Map();
+  const dates = new Set();
+
+  for (let r = 1; r < cells.length; r++) {
+    const line = cells[r] || [];
+    const shopName = normalizeHeader(line[cols.shopName]);
+    if (!shopName) continue; // 占位行 / 空行
+
+    const school = schoolOverride || (cols.school >= 0 ? normalizeHeader(line[cols.school]) : '');
+    const shopId = cols.shopId >= 0 ? toShopId(line[cols.shopId]) : '';
+    const status = cols.status >= 0 ? normalizeHeader(line[cols.status]) : '';
+    const shopKey = shopId || `${school}|${shopName}`;
+
+    const byDate = new Map();
+    for (const dc of dateCols) {
+      const cleaned = cleanValue(line[dc.col], dc.metricKey);
+      for (const reason of cleaned.reasons) {
+        warnings.push({
+          row: r + 1, // Excel 行号(1 基)
+          column: header[dc.col] == null ? '' : String(header[dc.col]),
+          raw: cleaned.rawText,
+          reason,
+          shopName,
+          date: dc.date,
+        });
+      }
+      if (!byDate.has(dc.date)) byDate.set(dc.date, {});
+      byDate.get(dc.date)[dc.metricKey] = cleaned;
+    }
+
+    if (!shops.has(shopKey)) {
+      shops.set(shopKey, {
+        key: shopKey,
+        shopId,
+        shopName,
+        school,
+        status,
+      });
+    }
+
+    for (const [date, metrics] of byDate) {
+      const values = METRIC_KEYS.map((k) => (metrics[k] ? metrics[k].value : null));
+      const quality = [];
+      const raw = {};
+      for (const k of METRIC_KEYS) {
+        const m = metrics[k];
+        if (!m) continue;
+        raw[k] = m.rawText;
+        for (const reason of m.reasons) {
+          if (reason === 'missing') continue; // 缺失不当作单元格异常,避免整表标黄
+          if (!quality.includes(reason)) quality.push(reason);
+        }
+      }
+      if (!shopId && !quality.includes('no-shop-id')) quality.push('no-shop-id');
+      const row = {
+        date,
+        shopKey,
+        shopId,
+        shopName,
+        school,
+        status,
+        order_cnt: metrics.order_cnt ? metrics.order_cnt.value : null,
+        revenue: metrics.revenue ? metrics.revenue.value : null,
+        exposure: metrics.exposure ? metrics.exposure.value : null,
+        click_rate: metrics.click_rate ? metrics.click_rate.value : null,
+        raw,
+        quality,
+      };
+      rows.push({ row, hasValue: values.some((v) => v !== null) });
+      dates.add(date);
+    }
+  }
+
+  // 整行(该店全部日期)都没有任何数值 → 视为空行跳过;有数据的店保留全部日期,
+  // 当天四项皆空也留一行(值全为 null),矩阵显示「–」,保证 店 × 天 结构完整。
+  const kept = [];
+  const shopsWithData = new Set();
+  for (const item of rows) {
+    if (item.hasValue) shopsWithData.add(item.row.shopKey);
+  }
+  for (const item of rows) {
+    if (!shopsWithData.has(item.row.shopKey)) continue;
+    kept.push(item.row);
+  }
+
+  return {
+    rows: kept,
+    shops: [...shops.values()].filter((s) => shopsWithData.has(s.key)),
+    warnings,
+    dates: [...dates].sort(),
+  };
+}
+
+/** 一步到位:Buffer → 解析结果 */
+function parseWorkbook(buffer, options = {}) {
+  const { sheets } = readXlsx(buffer);
+  if (!sheets.length) throw parseError('未找到工作表', 'OPS_FORMAT_UNSUPPORTED');
+  return parseManualOps(sheets[0].rows, options);
+}
+
+/** 解析器注册表(后续加平台导出格式时在此追加) */
+const PARSERS = [
+  { kind: 'manual-ops', detect, parse: parseManualOps },
+];
+
+function detectKind(cells) {
+  if (detect(cells) === 'manual-ops') return 'manual-ops';
+  if (detectPlatformExport(cells)) return 'platform-export';
+  return null;
+}
+
+function detectBuffer(buffer) {
+  const { sheets } = readXlsx(buffer);
+  const cells = sheets.length ? sheets[0].rows : [];
+  return detectKind(cells);
+}
+
+/** 一步到位(按格式分流):Buffer → { kind, rows, shops, warnings, dates } */
+function parseAny(buffer, options = {}) {
+  const { sheets } = readXlsx(buffer);
+  const cells = sheets.length ? sheets[0].rows : [];
+  const kind = detectKind(cells);
+  if (kind === 'manual-ops') return { kind, ...parseManualOps(cells, options) };
+  if (kind === 'platform-export') return parsePlatformExport(cells, options);
+  throw parseError('暂不支持该表格格式', 'OPS_FORMAT_UNSUPPORTED');
+}
+
+module.exports = {
+  METRICS,
+  METRIC_KEYS,
+  PARSERS,
+  detect,
+  detectPlatformExport,
+  detectKind,
+  parseAny,
+  parsePlatformExport,
+  detectBuffer,
+  parseManualOps,
+  parseWorkbook,
+  cleanValue,
+  parseDateColumns,
+};

+ 147 - 0
ops-store.js

@@ -0,0 +1,147 @@
+'use strict';
+
+/**
+ * ops 存储门面
+ * ----------------------------------------------------------------------------
+ * 读 config.storage 决定驱动(默认 SQLite),把驱动实例藏起来,对外只暴露业务接口。
+ * 关键约束:初始化失败不能拖垮主服务 —— 所有失败都收敛成 health() 的 ok:false,
+ * 只有「店铺运营」相关接口会返回 503。
+ */
+
+const path = require('path');
+
+const ROOT_DIR = __dirname;
+
+function pickDriver(storage) {
+  const raw = String((storage && storage.driver) || 'sqlite').toLowerCase();
+  return raw === 'postgres' || raw === 'postgresql' || raw === 'pg' ? 'postgres' : 'sqlite';
+}
+
+function createOpsStore(config) {
+  const storage = (config && config.storage) || {};
+  const driver = pickDriver(storage);
+
+  let impl = null;
+  let initPromise = null;
+  let lastFailure = null;
+
+  function build() {
+    if (driver === 'postgres') {
+      const pgCfg = storage.postgres || {};
+      let connectionString = process.env.OPS_DATABASE_URL || pgCfg.connectionString || '';
+      // 也支持「主机/库/用户/密码」分开填(设置页用这种)
+      if (!String(connectionString).trim() && pgCfg.host && pgCfg.database) {
+        const user = encodeURIComponent(pgCfg.user || 'postgres');
+        const pass = pgCfg.password ? ':' + encodeURIComponent(pgCfg.password) : '';
+        const port = pgCfg.port || 5432;
+        connectionString = `postgres://${user}${pass}@${pgCfg.host}:${port}/${pgCfg.database}`;
+        if (pgCfg.ssl) connectionString += (connectionString.includes('?') ? '&' : '?') + 'sslmode=require';
+      }
+      if (!String(connectionString).trim()) {
+        const err = new Error('未配置 PostgreSQL 连接信息');
+        err.reason = 'storage-not-configured';
+        throw err;
+      }
+      const { createPgStore } = require('./server/stores/ops-pg.js');
+      return createPgStore({ ...pgCfg, connectionString });
+    }
+    const sqliteCfg = storage.sqlite || {};
+    const file = sqliteCfg.file
+      ? path.resolve(ROOT_DIR, String(sqliteCfg.file))
+      : path.join(ROOT_DIR, 'data', 'ops.db');
+    const { createSqliteStore } = require('./server/stores/ops-sqlite.js');
+    return createSqliteStore({ file });
+  }
+
+  function get() {
+    if (impl) return impl;
+    try {
+      impl = build();
+      lastFailure = null;
+      return impl;
+    } catch (e) {
+      lastFailure = e;
+      throw e;
+    }
+  }
+
+  function reset() {
+    impl = null;
+    initPromise = null;
+  }
+
+  async function init() {
+    if (!initPromise) {
+      initPromise = Promise.resolve()
+        .then(() => get())
+        .then((store) => store.init())
+        .then(() => get())
+        .catch((e) => {
+          lastFailure = e;
+          initPromise = null;
+          throw e;
+        });
+    }
+    return initPromise;
+  }
+
+  /** never throws:{ ok, kind, reason?, detail? } */
+  async function health() {
+    try {
+      await init();
+      const result = await get().health();
+      return { ...result, kind: driver };
+    } catch (e) {
+      return {
+        ok: false,
+        kind: driver,
+        reason: e.reason || (e.code === 'MODULE_NOT_FOUND' ? 'driver-missing' : 'storage-unreachable'),
+        detail: e.message || String(e),
+        stack: undefined,
+      };
+    }
+  }
+
+  /** 包装:统一先 ensure init,再调用驱动方法 */
+  function call(name) {
+    return async (...args) => {
+      await init();
+      return get()[name](...args);
+    };
+  }
+
+  return {
+    kind: driver,
+    init,
+    health,
+    reset,
+    close: async () => {
+      if (impl && typeof impl.close === 'function') {
+        try { await impl.close(); } catch { /* ignore */ }
+      }
+      reset();
+    },
+    withTransaction: async (fn) => {
+      await init();
+      return get().withTransaction(fn);
+    },
+    findUploadBySha: call('findUploadBySha'),
+    listUploads: call('listUploads'),
+    getUpload: call('getUpload'),
+    getContent: call('getContent'),
+    queryRows: call('queryRows'),
+    queryPlatformRows: call('queryPlatformRows'),
+    insertReport: call('insertReport'),
+    getReportCache: call('getReportCache'),
+    saveReportCache: call('saveReportCache'),
+    listReports: call('listReports'),
+    getReport: call('getReport'),
+    deleteReport: call('deleteReport'),
+    summary: call('summary'),
+    get failure() {
+      return lastFailure;
+    },
+  };
+}
+
+module.exports = { createOpsStore, pickDriver };

+ 150 - 0
package-lock.json

@@ -9,7 +9,11 @@
       "version": "1.0.0",
       "dependencies": {
         "jpeg-js": "^0.4.4",
+        "pg": "^8.23.1",
         "pngjs": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=22.13.0"
       }
     },
     "node_modules/jpeg-js": {
@@ -18,6 +22,95 @@
       "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
       "license": "BSD-3-Clause"
     },
+    "node_modules/pg": {
+      "version": "8.23.1",
+      "resolved": "https://registry.npmmirror.com/pg/-/pg-8.23.1.tgz",
+      "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.14.1",
+        "pg-pool": "^3.14.0",
+        "pg-protocol": "^1.16.1",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.4.1"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+      "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.14.1",
+      "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+      "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz",
+      "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.16.1.tgz",
+      "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
     "node_modules/pngjs": {
       "version": "7.0.0",
       "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-7.0.0.tgz",
@@ -26,6 +119,63 @@
       "engines": {
         "node": ">=14.19.0"
       }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
     }
   }
 }

+ 10 - 1
package.json

@@ -5,10 +5,19 @@
   "scripts": {
     "start": "node server.js",
     "smoke": "node scripts/smoke.js",
-    "eval": "node scripts/eval-ask.js"
+    "eval": "node scripts/eval-ask.js",
+    "test:ops": "node scripts/test-ops-import.js",
+    "db:init": "node scripts/db-init.js",
+    "db:export": "node scripts/db-export.js",
+    "db:import": "node scripts/db-import.js",
+    "test:schools": "node scripts/test-schools.js"
   },
   "dependencies": {
     "jpeg-js": "^0.4.4",
+    "pg": "^8.23.1",
     "pngjs": "^7.0.0"
+  },
+  "engines": {
+    "node": ">=22.13.0"
   }
 }

+ 89 - 0
public/app.js

@@ -34,6 +34,10 @@
     tasks: document.getElementById('viewTasks'),
     history: document.getElementById('viewHistory'),
     reports: document.getElementById('viewReports'),
+    cockpit: document.getElementById('viewCockpit'),
+    report: document.getElementById('viewReport'),
+    ops: document.getElementById('viewOps'),
+    schools: document.getElementById('viewSchools'),
     lake: document.getElementById('viewLake'),
     analysis: document.getElementById('viewAnalysis'),
     ask: document.getElementById('viewAsk'),
@@ -98,6 +102,7 @@
     aitableSecretInput.value = '';
     aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
     await loadModels(settings.model);
+    await loadStorageSettings();
   }
 
   function setPanelStatus(badgeId, message, ok) {
@@ -288,6 +293,83 @@
   }
   window.shanAitableAction = aitableAction;
 
+  async function loadStorageSettings() {
+    try {
+      const response = await fetch('/api/settings/storage');
+      if (!response.ok) return;
+      const st = await response.json();
+      document.getElementById('settingsStorageDriver').value = st.driver || 'sqlite';
+      document.getElementById('settingsPgHost').value = st.postgres.host || '';
+      document.getElementById('settingsPgPort').value = st.postgres.port || 5432;
+      document.getElementById('settingsPgDatabase').value = st.postgres.database || '';
+      document.getElementById('settingsPgUser').value = st.postgres.user || '';
+      document.getElementById('settingsPgSsl').checked = !!st.postgres.ssl;
+      const pw = document.getElementById('settingsPgPassword');
+      pw.value = '';
+      pw.placeholder = st.postgres.passwordSet ? '已设置,留空保持不变' : '数据库密码';
+      toggleStorageFields();
+      if (!st.pgInstalled) setPanelStatus('storageTestStatus', '未安装 pg 驱动', false);
+    } catch { /* 读取失败不阻塞设置页 */ }
+  }
+
+  function toggleStorageFields() {
+    const isPg = document.getElementById('settingsStorageDriver').value === 'postgres';
+    document.getElementById('settingsPgFields').hidden = !isPg;
+  }
+
+  function storagePayload() {
+    return {
+      driver: document.getElementById('settingsStorageDriver').value,
+      pgHost: val('settingsPgHost'),
+      pgPort: Number(val('settingsPgPort')) || 5432,
+      pgDatabase: val('settingsPgDatabase'),
+      pgUser: val('settingsPgUser'),
+      pgPassword: document.getElementById('settingsPgPassword').value,
+      pgSsl: document.getElementById('settingsPgSsl').checked,
+    };
+  }
+
+  async function testStorage() {
+    const btn = document.getElementById('btnTestStorage');
+    btn.disabled = true;
+    setPanelStatus('storageTestStatus', '连接中…');
+    try {
+      const response = await fetch('/api/settings/storage/test', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(storagePayload()),
+      });
+      const data = await response.json();
+      if (data.ok) setPanelStatus('storageTestStatus', `连接成功 · ${data.version || ''}`, true);
+      else setPanelStatus('storageTestStatus', data.error || '连接失败', false);
+    } catch (error) {
+      setPanelStatus('storageTestStatus', error.message, false);
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
+  async function saveStorageSettings() {
+    const btn = document.getElementById('btnSaveStorage');
+    btn.disabled = true;
+    setPanelStatus('storageSaveStatus', '保存中…');
+    try {
+      const response = await fetch('/api/settings/storage', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(storagePayload()),
+      });
+      const data = await response.json();
+      if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
+      setPanelStatus('storageSaveStatus', data.restartRequired ? '已保存,重启服务后生效' : '已保存', true);
+      await loadStorageSettings();
+    } catch (error) {
+      setPanelStatus('storageSaveStatus', error.message, false);
+    } finally {
+      btn.disabled = false;
+    }
+  }
+
   async function initSettings() {
     // 事件绑定与数据加载分离:加载失败重进设置页时不会重复 addEventListener
     if (!settingsBound) {
@@ -303,6 +385,9 @@
       document.getElementById('btnTestImage').addEventListener('click', testImageApi);
       document.getElementById('btnTestChat').addEventListener('click', testChatModel);
       document.getElementById('btnTestAitable').addEventListener('click', () => aitableAction('test'));
+      document.getElementById('btnSaveStorage').addEventListener('click', saveStorageSettings);
+      document.getElementById('btnTestStorage').addEventListener('click', testStorage);
+      document.getElementById('settingsStorageDriver').addEventListener('change', toggleStorageFields);
       const bindHelpPopover = (btnId, popId) => {
         const btn = document.getElementById(btnId);
         const pop = document.getElementById(popId);
@@ -399,6 +484,10 @@
     if (window.shanTasksSetActive) window.shanTasksSetActive(name === 'tasks'); // 离开任务页清掉轮询
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
+    if (name === 'cockpit' && window.shanCockpitInit) window.shanCockpitInit();
+    if (name === 'report' && window.shanReportInit) window.shanReportInit();
+    if (name === 'ops' && window.shanOpsInit) window.shanOpsInit();
+    if (name === 'schools' && window.shanSchoolsInit) window.shanSchoolsInit();
     if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表
     if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
     if (name === 'ask' && window.shanAskInit) window.shanAskInit();

BIN
public/assets/logo.png


+ 456 - 0
public/cockpit.js

@@ -0,0 +1,456 @@
+'use strict';
+
+// 经营驾驶舱:KPI + 趋势 / 门店排行 / 转化漏斗 / 收入结构
+// 数据来自 GET /api/ops/dashboard(服务端已按统一口径聚合)
+(function () {
+  const $ = (id) => document.getElementById(id);
+  const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#b8b2ad', green: '#2e7d4f', red: '#c0392b' };
+
+  const KPI_DEFS = [
+    { key: 'revenue', label: '营业额', unit: 'money' },
+    { key: 'orders', label: '有效订单', unit: 'int' },
+    { key: 'avgTicket', label: '客单价', unit: 'money' },
+    { key: 'exposure', label: '曝光人数', unit: 'int' },
+    { key: 'conversion', label: '进店转化率', unit: 'rate' },
+    { key: 'rating', label: '店铺评分', unit: 'num' },
+  ];
+
+  const state = {
+    inited: false,
+    loading: false,
+    data: null,
+    from: '',
+    to: '',
+    schools: null,
+    shops: null,
+    charts: {},
+    insightPoints: [],
+    pending: false,
+  };
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function ymdToInput(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
+  }
+
+  function inputToYmd(value) {
+    return String(value || '').replace(/-/g, '');
+  }
+
+  function shortDate(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
+  }
+
+  function fmtValue(value, unit) {
+    if (value === null || value === undefined) return '–';
+    const n = Number(value);
+    if (!Number.isFinite(n)) return '–';
+    if (unit === 'rate') return (n * 100).toFixed(2) + '%';
+    if (unit === 'num') return n.toFixed(2);
+    if (unit === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+    return n.toLocaleString('zh-CN');
+  }
+
+  function fmtDelta(delta, unit) {
+    if (delta === null || delta === undefined) return '<span class="ckpt-delta muted">—</span>';
+    const up = delta > 0;
+    const cls = delta === 0 ? 'muted' : (up ? 'up' : 'down');
+    const arrow = delta === 0 ? '' : (up ? '↑' : '↓');
+    const text = unit === 'pp' ? Math.abs(delta).toFixed(1) + 'pp' : Math.abs(delta).toFixed(1) + '%';
+    return `<span class="ckpt-delta ${cls}">${arrow} ${text}</span>`;
+  }
+
+  async function api(url, options) {
+    const res = await fetch(url, options);
+    const body = await res.json().catch(() => null);
+    return { res, body };
+  }
+
+  // ---------- 骨架 ----------
+
+  function renderShell() {
+    const root = $('cockpitRoot');
+    if (!root) return;
+    root.innerHTML = `
+      <div class="ops-bar ckpt-bar">
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">日期</span>
+          <input type="date" class="ops-input" id="ckptFrom">
+          <span class="ops-tilde">~</span>
+          <input type="date" class="ops-input" id="ckptTo">
+        </div>
+        <div class="ops-bar-group"><span class="ops-bar-label">学校</span><div class="ops-multi" id="ckptSchool"></div></div>
+        <div class="ops-bar-group"><span class="ops-bar-label">店铺</span><div class="ops-multi" id="ckptShop"></div></div>
+      </div>
+      <section class="ckpt-ai" id="ckptAi">
+        <div class="ckpt-ai-head">
+          <div class="ckpt-ai-title"><span class="ckpt-ai-badge">AI</span><h3>经营解读</h3><span class="ckpt-ai-meta" id="ckptAiMeta"></span></div>
+          <div class="ckpt-ai-actions">
+            <button class="btn btn-ghost btn-small" id="ckptAiCopy" hidden>复制</button>
+            <button class="btn btn-primary btn-small" id="ckptAiBtn">生成解读</button>
+          </div>
+        </div>
+        <div class="ckpt-ai-body" id="ckptAiBody">点击「生成解读」,模型会根据当前筛选范围的数据给出结论与建议。</div>
+      </section>
+      <div class="ckpt-kpis" id="ckptKpis"></div>
+      <div class="ckpt-grid">
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>营业额 / 订单趋势</h3><span id="ckptTrendNote"></span></div><div class="ckpt-chart" id="ckptTrend"></div></section>
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>门店营业额 Top 10</h3><span></span></div><div class="ckpt-chart" id="ckptRank"></div></section>
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>转化漏斗</h3><span>曝光 → 进店 → 下单</span></div><div class="ckpt-chart" id="ckptFunnel"></div></section>
+        <section class="ckpt-card"><div class="ckpt-card-head"><h3>收入结构</h3><span>收入 / 支出 / 补贴</span></div><div class="ckpt-chart" id="ckptStructure"></div></section>
+      </div>
+      <div class="ckpt-empty" id="ckptEmpty" hidden>
+        <p>所选范围没有数据</p>
+        <small>调整日期,或换一个学校 / 店铺</small>
+      </div>`;
+
+    $('ckptAiBtn').addEventListener('click', () => requestInsight(true));
+    $('ckptAiCopy').addEventListener('click', () => {
+      const text = state.insightPoints.join('\n');
+      if (!text) return;
+      navigator.clipboard.writeText(text).then(() => {
+        const btn = $('ckptAiCopy');
+        btn.textContent = '已复制';
+        setTimeout(() => { btn.textContent = '复制'; }, 1500);
+      }).catch(() => {});
+    });
+
+    $('ckptFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadData(); });
+    $('ckptTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadData(); });
+
+    state.charts.trend = echarts.init($('ckptTrend'));
+    state.charts.rank = echarts.init($('ckptRank'));
+    state.charts.funnel = echarts.init($('ckptFunnel'));
+    state.charts.structure = echarts.init($('ckptStructure'));
+
+    window.bindMultiSelectDismiss();
+    window.addEventListener('resize', () => {
+      Object.values(state.charts).forEach((chart) => chart && chart.resize());
+    });
+  }
+
+  function mountFilters(summary) {
+    const schools = (summary.schools || []).map((s) => ({ key: s.name, label: s.name, hint: `${s.shopCount} 家` }));
+    window.createMultiSelect($('ckptSchool'), {
+      label: '学校',
+      items: schools,
+      selected: state.schools ? [...state.schools] : [],
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => { state.schools = keys.length ? new Set(keys) : null; loadData(); },
+    });
+
+    const shops = (summary.shops || []).map((s) => ({
+      key: String(s.key || s.shopId || ''),
+      label: s.shopName || s.key,
+      hint: s.school || '',
+    })).filter((i) => i.key);
+    window.createMultiSelect($('ckptShop'), {
+      label: '店铺',
+      items: shops,
+      selected: state.shops ? [...state.shops] : [],
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => { state.shops = keys.length ? new Set(keys) : null; loadData(); },
+    });
+  }
+
+  // ---------- 渲染 ----------
+
+  function renderKpis(kpis) {
+    const box = $('ckptKpis');
+    if (!box) return;
+    if (!kpis) {
+      box.innerHTML = '';
+      return;
+    }
+    box.innerHTML = KPI_DEFS.map((def) => {
+      const item = kpis[def.key] || { value: null, delta: null, unit: def.unit };
+      return `<div class="ckpt-kpi">
+        <div class="ckpt-kpi-label">${def.label}</div>
+        <div class="ckpt-kpi-value">${fmtValue(item.value, def.unit)}</div>
+        <div class="ckpt-kpi-foot">${fmtDelta(item.delta, def.unit)}<span class="ckpt-kpi-hint">较上期</span></div>
+      </div>`;
+    }).join('');
+  }
+
+  function renderTrend(data) {
+    const chart = state.charts.trend;
+    const dates = data.trend.map((t) => shortDate(t.date));
+    chart.setOption({
+      grid: { left: 56, right: 52, top: 24, bottom: 32 },
+      tooltip: { trigger: 'axis' },
+      legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+      xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } },
+      yAxis: [
+        { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
+        { type: 'value', splitLine: { show: false }, axisLabel: { color: '#8f8880' } },
+      ],
+      series: [
+        { name: '营业额', type: 'bar', barMaxWidth: 28, itemStyle: { color: COLORS.main, borderRadius: [4, 4, 0, 0] }, data: data.trend.map((t) => t.revenue) },
+        { name: '订单', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, lineStyle: { width: 2, color: '#7a9cc6' }, itemStyle: { color: '#7a9cc6' }, data: data.trend.map((t) => t.orders) },
+      ],
+    }, true);
+  }
+
+  function renderRanking(data) {
+    const rows = data.ranking.slice().reverse();
+    state.charts.rank.setOption({
+      grid: { left: 8, right: 48, top: 10, bottom: 10, containLabel: true },
+      tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+      xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
+      yAxis: {
+        type: 'category',
+        data: rows.map((r) => r.shopName),
+        axisLine: { show: false },
+        axisTick: { show: false },
+        axisLabel: { color: '#5f5852', width: 150, overflow: 'truncate', fontSize: 11 },
+      },
+      series: [{
+        type: 'bar',
+        barMaxWidth: 16,
+        itemStyle: { color: COLORS.soft, borderRadius: [0, 4, 4, 0] },
+        label: {
+          show: true,
+          position: 'right',
+          fontSize: 11,
+          color: '#6f6862',
+          formatter: (p) => Number(p.value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }),
+        },
+        data: rows.map((r) => r.revenue),
+      }],
+    }, true);
+  }
+
+  function renderFunnel(data) {
+    const f = data.funnel;
+    const stages = [
+      { name: '曝光', value: f.exposure },
+      { name: '进店', value: f.visit },
+      { name: '下单', value: f.order },
+    ];
+    const rate = (a, b) => (b > 0 ? ((a / b) * 100).toFixed(1) + '%' : '—');
+    state.charts.funnel.setOption({
+      tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${Number(p.value).toLocaleString('zh-CN')}` },
+      series: [{
+        type: 'funnel',
+        left: '8%',
+        right: '8%',
+        top: 16,
+        bottom: 16,
+        minSize: '28%',
+        sort: 'descending',
+        gap: 4,
+        label: {
+          show: true,
+          position: 'inside',
+          color: '#fff',
+          fontSize: 12,
+          formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}`,
+        },
+        itemStyle: { borderWidth: 0 },
+        color: [COLORS.main, '#ff9a70', '#ffc4a8'],
+        data: stages,
+      }],
+      graphic: [
+        { type: 'text', right: 10, top: 8, style: { text: `进店 ${rate(f.visit, f.exposure)}`, fill: '#8f8880', fontSize: 11 } },
+        { type: 'text', right: 10, bottom: 8, style: { text: `下单 ${rate(f.order, f.visit)}`, fill: '#8f8880', fontSize: 11 } },
+      ],
+    }, true);
+  }
+
+  function renderStructure(data) {
+    const dates = data.structure.map((s) => shortDate(s.date));
+    state.charts.structure.setOption({
+      grid: { left: 56, right: 20, top: 28, bottom: 32 },
+      tooltip: { trigger: 'axis' },
+      legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+      xAxis: { type: 'category', data: dates, axisLine: { lineStyle: { color: '#e5e0da' } }, axisTick: { show: false }, axisLabel: { color: '#8f8880' } },
+      yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } }, axisLabel: { color: '#8f8880' } },
+      series: [
+        { name: '收入', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.income) },
+        { name: '支出', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#c9c2ba', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.expense) },
+        { name: '补贴', type: 'bar', barMaxWidth: 22, itemStyle: { color: '#ffd6c2', borderRadius: [3, 3, 0, 0] }, data: data.structure.map((s) => s.subsidy) },
+      ],
+    }, true);
+  }
+
+  // ---------- AI 解读 ----------
+
+  /** 报告链接:带上当前学校/店铺筛选,避免打开后变成全部门店 */
+  function reportLink(from, to) {
+    const params = new URLSearchParams({ view: 'report', from, to });
+    if (state.schools && state.schools.size) params.set('schools', [...state.schools].join(','));
+    if (state.shops && state.shops.size) params.set('shops', [...state.shops].join(','));
+    return '/?' + params.toString();
+  }
+
+  async function requestInsight(refresh) {
+    const body = $('ckptAiBody');
+    const btn = $('ckptAiBtn');
+    if (!body || !btn) return;
+    btn.disabled = true;
+    btn.textContent = '分析中…';
+    body.innerHTML = '<span class="ckpt-ai-loading">AI 正在读取数据并生成结论…</span>';
+    $('ckptAiMeta').textContent = '';
+    $('ckptAiCopy').hidden = true;
+
+    const payload = {
+      from: state.from,
+      to: state.to,
+      schools: state.schools ? [...state.schools].join(',') : '',
+      shops: state.shops ? [...state.shops].join(',') : '',
+      refresh: !!refresh,
+    };
+    try {
+      const { res, body: data } = await api('/api/ops/insights', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload),
+      });
+      if (!res.ok) throw new Error((data && (data.detail || data.error)) || ('HTTP ' + res.status));
+      if (data && data.enabled === false) {
+        body.innerHTML = data.reason === 'chat-not-configured'
+          ? '未配置对话模型,当前只能显示规则版摘要。请到「系统设置 → API 设置」配置后重新生成。'
+          : '经营解读暂不可用。';
+        return;
+      }
+      if (data && data.note) {
+        body.textContent = data.note;
+        return;
+      }
+      const points = (data && data.points) || [];
+      if (!points.length) {
+        body.textContent = '模型没有返回有效结论,请稍后重试。';
+        return;
+      }
+      state.insightPoints = points;
+      const rangeFrom = data.range && data.range.from ? String(data.range.from).replace(/-/g, '') : '';
+      const rangeTo = data.range && data.range.to ? String(data.range.to).replace(/-/g, '') : '';
+      const foot = [
+        data.aiError ? `<span class="ckpt-ai-note">${esc(data.aiError)}</span>` : '',
+        rangeFrom && rangeTo ? `<a class="ckpt-ai-link" href="${reportLink(rangeFrom, rangeTo)}">查看完整报告 →</a>` : '',
+      ].filter(Boolean).join('');
+      body.innerHTML = `<ul class="ckpt-ai-list">${points.map((point) => `<li>${esc(point)}</li>`).join('')}</ul>${foot ? `<div class="ckpt-ai-foot">${foot}</div>` : ''}`;
+      const time = data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) : '';
+      $('ckptAiMeta').textContent = [data.source === 'llm' ? 'AI 生成' : '规则生成', data.model, time, data.cached ? '缓存' : ''].filter(Boolean).join(' · ');
+      $('ckptAiCopy').hidden = false;
+    } catch (e) {
+      body.innerHTML = `<span class="ckpt-ai-error">生成失败:${esc(e.message || e)}</span>`;
+    } finally {
+      btn.disabled = false;
+      btn.textContent = refresh ? '重新生成' : '生成解读';
+    }
+  }
+
+  // ---------- 数据 ----------
+
+  async function loadData() {
+    if (state.loading) { state.pending = true; return; }
+    state.loading = true;
+    const params = new URLSearchParams();
+    if (state.from) params.set('from', state.from);
+    if (state.to) params.set('to', state.to);
+    if (state.schools && state.schools.size) params.set('schools', [...state.schools].join(','));
+    if (state.shops && state.shops.size) params.set('shops', [...state.shops].join(','));
+    try {
+      const { res, body } = await api('/api/ops/dashboard?' + params.toString());
+      if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
+      state.data = body;
+      const empty = !body.kpis || !body.trend.length;
+      $('ckptEmpty').hidden = !empty;
+      $('ckptKpis').hidden = empty;
+      document.querySelector('.ckpt-grid').hidden = empty;
+      if (!empty) {
+        state.from = body.from || state.from;
+        state.to = body.to || state.to;
+        $('ckptFrom').value = ymdToInput(state.from);
+        $('ckptTo').value = ymdToInput(state.to);
+        renderKpis(body.kpis);
+        renderTrend(body);
+        renderRanking(body);
+        renderFunnel(body);
+        renderStructure(body);
+        $('ckptTrendNote').textContent = body.prev ? `对比 ${shortDate(body.prev.from)} ~ ${shortDate(body.prev.to)}` : '暂无上期可比';
+        if (state.insightPoints.length) {
+          state.insightPoints = [];
+          $('ckptAiBody').innerHTML = '筛选范围已变化,点击「生成解读」重新分析。';
+          $('ckptAiMeta').textContent = '';
+          $('ckptAiCopy').hidden = true;
+        }
+        Object.values(state.charts).forEach((chart) => chart && chart.resize());
+      }
+    } catch (e) {
+      $('ckptEmpty').hidden = false;
+      $('ckptEmpty').querySelector('p').textContent = '驾驶舱加载失败';
+      $('ckptEmpty').querySelector('small').textContent = e.message || String(e);
+    } finally {
+      state.loading = false;
+      if (state.pending) {
+        state.pending = false;
+        loadData();
+      }
+    }
+  }
+
+  /** 数据变更后刷新:重拉筛选选项与聚合数据,不重建图表 */
+  async function refresh() {
+    const { body } = await api('/api/ops/summary');
+    if (!body || !body.enabled) return;
+    // 清理已不存在于新数据的筛选值
+    const schoolNames = new Set((body.schools || []).map((s) => s.name));
+    if (state.schools) {
+      const next = new Set([...state.schools].filter((name) => schoolNames.has(name)));
+      state.schools = next.size ? next : null;
+    }
+    const shopKeys = new Set((body.shops || []).map((s) => String(s.key || s.shopId)));
+    if (state.shops) {
+      const next = new Set([...state.shops].filter((key) => shopKeys.has(key)));
+      state.shops = next.size ? next : null;
+    }
+    mountFilters(body);
+    await loadData();
+  }
+
+  async function init() {
+    if (state.inited) return;
+    state.inited = true;
+    renderShell();
+    const { body } = await api('/api/ops/summary');
+    const summary = body || { enabled: false, schools: [], shops: [] };
+    if (summary.enabled) {
+      if (summary.coverage) {
+        state.from = summary.coverage.from || '';
+        state.to = summary.coverage.to || '';
+        $('ckptFrom').value = ymdToInput(state.from);
+        $('ckptTo').value = ymdToInput(state.to);
+      }
+      mountFilters(summary);
+      await loadData();
+    } else {
+      $('ckptEmpty').hidden = false;
+      $('ckptEmpty').querySelector('p').textContent = '数据暂不可用';
+      $('ckptEmpty').querySelector('small').textContent = summary.reason || '存储未连接';
+    }
+  }
+
+  window.addEventListener('ops:data-changed', () => {
+    if (state.inited) refresh();
+  });
+
+  window.shanCockpitInit = function () {
+    init();
+    const refresh = $('btnCockpitRefresh');
+    if (refresh && !refresh.dataset.bound) {
+      refresh.dataset.bound = '1';
+      refresh.addEventListener('click', () => loadData());
+    }
+  };
+})();

+ 178 - 0
public/index.html

@@ -40,6 +40,10 @@
 
     <div class="nav-section-label">经营分析</div>
     <button class="nav-item" data-view="reports"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg></span><span>数据采集</span></button>
+    <button class="nav-item" data-view="schools"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="10" r="3"/><path d="M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Z"/></svg></span><span>校区地图</span></button>
+    <button class="nav-item" data-view="cockpit"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 13a9 9 0 0 1 18 0"/><path d="M12 13l4-4"/><circle cx="12" cy="13" r="1.6"/><path d="M3 17h18"/></svg></span><span>经营驾驶舱</span></button>
+    <button class="nav-item" data-view="report"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3h7l4 4v14H7z"/><path d="M14 3v4h4"/><path d="M10 12h6M10 16h6"/></svg></span><span>经营报告</span></button>
+    <button class="nav-item" data-view="ops"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h16"/><path d="M7 20V9M12 20V4M17 20v-7"/></svg></span><span>店铺运营</span></button>
     <button class="nav-item" data-view="lake"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.5"/><path d="M5 5v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5"/><path d="M5 11v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6"/></svg></span><span>数据中台</span></button>
     <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span><span class="nav-beta">Beta</span></button>
     <button class="nav-item" data-view="ask"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="8" width="16" height="11" rx="3"/><path d="M12 8V5"/><circle cx="12" cy="3.6" r="1.4"/><path d="M9.5 13.5h.01M14.5 13.5h.01"/><path d="M9.5 16.2h5"/></svg></span><span>智能问数</span><span class="nav-beta">Beta</span></button>
@@ -326,6 +330,79 @@
       </main>
     </div>
 
+    <!-- ==================== 校区地图 ==================== -->
+    <div id="viewSchools" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>校区地图</h1>
+            <div class="sub">已启用的学校点亮在中国地图上,点击可查看关联的运营数据</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnSchoolsReset">回到全国</button>
+          </div>
+        </div>
+        <div class="schools-layout">
+          <div class="schools-map-card"><div id="schoolsMap" class="schools-map"></div><div id="schoolsLegend" class="schools-legend"></div></div>
+          <aside class="schools-side">
+            <div class="schools-side-head"><b>学校</b><span id="schoolsCount"></span></div>
+            <div id="schoolsList" class="schools-list"></div>
+          </aside>
+        </div>
+      </main>
+    </div>
+
+    <!-- ==================== 经营驾驶舱 ==================== -->
+    <div id="viewCockpit" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>经营驾驶舱</h1>
+            <div class="sub">一屏看清经营大盘:规模、效率、转化与结构</div>
+          </div>
+          <div class="toolbar">
+            <button class="btn btn-ghost btn-small" id="btnCockpitRefresh">刷新</button>
+          </div>
+        </div>
+        <div id="cockpitRoot"></div>
+      </main>
+    </div>
+
+    <!-- ==================== 经营报告 ==================== -->
+    <div id="viewReport" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>经营报告</h1>
+            <div class="sub">按统一口径生成的分析报告,图表可交互,可下载 PDF</div>
+          </div>
+        </div>
+        <div id="reportRoot"></div>
+      </main>
+    </div>
+
+    <!-- ==================== 店铺运营 ==================== -->
+    <div id="viewOps" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>店铺运营</h1>
+            <div class="sub">上传商家后台导出的人工统计表,按学校 / 店铺 / 日期查看运营指标</div>
+          </div>
+          <div class="toolbar">
+            <div id="opsHeaderTools"></div>
+            <button class="btn btn-ghost btn-small" id="btnOpsRecords">导入记录</button>
+            <button class="btn btn-primary btn-small" id="btnOpsUpload">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="M7 9l5-5 5 5"/><path d="M4 20h16"/></svg>
+              上传文件
+            </button>
+          </div>
+        </div>
+        <div id="opsBar"></div>
+        <div id="opsBody"></div>
+      </main>
+    </div>
+
     <!-- ==================== 数据中台 ==================== -->
     <div id="viewLake" class="view">
       <main class="content">
@@ -817,6 +894,53 @@
                 </div>
               </div>
             </div>
+
+            <div class="panel panel-narrow">
+              <div class="stage-marker"><span class="num">5</span><span>云端数据库</span></div>
+              <span class="panel-watermark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/></svg></span>
+              <div class="panel-body">
+                <div class="field">
+                  <label>存储方式</label>
+                  <select id="settingsStorageDriver">
+                    <option value="sqlite">本地 SQLite(默认)</option>
+                    <option value="postgres">云端 PostgreSQL</option>
+                  </select>
+                  <div class="help">切换后需重启服务生效;已有数据可用 npm run db:export / db:import 迁移</div>
+                </div>
+                <div id="settingsPgFields" hidden>
+                  <div class="field">
+                    <label>主机 / 端口</label>
+                    <div style="display:flex;gap:8px;">
+                      <input type="text" id="settingsPgHost" placeholder="111.229.143.134" style="flex:1;">
+                      <input type="text" id="settingsPgPort" placeholder="5432" style="width:88px;">
+                    </div>
+                  </div>
+                  <div class="field">
+                    <label>数据库 / 用户</label>
+                    <input type="text" id="settingsPgDatabase" placeholder="smart_ops" autocomplete="off">
+                    <input type="text" id="settingsPgUser" placeholder="smart_ops" autocomplete="off" style="margin-top:6px;">
+                  </div>
+                  <div class="field">
+                    <label>密码</label>
+                    <input type="password" id="settingsPgPassword" placeholder="数据库密码" autocomplete="off">
+                  </div>
+                  <div class="field">
+                    <label style="display:flex;align-items:center;gap:6px;">
+                      <input type="checkbox" id="settingsPgSsl">
+                      <span>使用 SSL(云数据库要求时勾选)</span>
+                    </label>
+                  </div>
+                </div>
+                <div class="settings-links">
+                  <button type="button" class="settings-link settings-link-btn" id="btnTestStorage"><span>测试连接</span></button>
+                  <span id="storageTestStatus" class="badge"></span>
+                </div>
+                <div class="panel-save-row">
+                  <button class="btn btn-primary btn-small" id="btnSaveStorage">保存设置</button>
+                  <span id="storageSaveStatus" class="badge"></span>
+                </div>
+              </div>
+            </div>
           </div>
         </div>
       </main>
@@ -1049,6 +1173,55 @@
   </div>
 </div>
 
+<!-- 店铺运营 · 上传弹窗 -->
+<div class="modal-overlay" id="opsUploadModal" hidden>
+  <div class="modal ops-upload-modal">
+    <div class="modal-header">
+      <div class="modal-title"><span>上传店铺运营数据</span></div>
+      <button class="modal-close" id="opsUploadClose" type="button" aria-label="关闭">×</button>
+    </div>
+    <div class="modal-body">
+      <div class="ops-drop" id="opsDrop">
+        <b>拖拽 .xlsx 文件到这里(可多选)</b>
+        <small>或点击选择文件;支持商家后台导出的人工统计表,一次最多 30 个</small>
+      </div>
+      <input type="file" id="opsFile" accept=".xlsx" multiple hidden>
+      <div class="ops-file-list" id="opsFileList"></div>
+      <div class="ops-field">
+        <label for="opsYear">数据年份</label>
+        <input type="number" class="ops-input" id="opsYear" min="2000" max="2100" value="2026">
+        <small>表里的日期只有「9.9」这种格式,没有年份,需要你确认</small>
+      </div>
+      <div class="ops-field">
+        <label for="opsSchool">学校</label>
+        <select class="ops-input ops-select" id="opsSchool">
+          <option value="">自动(读取表格里的「学校名称」列)</option>
+          <option value="__custom">手动输入…</option>
+        </select>
+        <input type="text" class="ops-input" id="opsSchoolCustom" placeholder="输入学校名称" hidden>
+        <small>表格里没有「学校名称」列,或想把这批数据归到指定学校时,在这里选或手动填</small>
+      </div>
+      <label class="ops-check"><input type="checkbox" id="opsForce"> 覆盖已导入的文件(同一文件重新导入时替换旧数据)</label>
+      <div class="ops-upload-result" id="opsUploadResult"></div>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-small" id="opsUploadCancel" type="button">关闭</button>
+      <button class="btn btn-primary btn-small" id="opsUploadStart" type="button">开始导入</button>
+    </div>
+  </div>
+</div>
+
+<!-- 店铺运营 · 导入记录 -->
+<div class="modal-overlay" id="opsRecordsModal" hidden>
+  <div class="modal ops-records-modal">
+    <div class="modal-header">
+      <div class="modal-title"><span>导入记录</span></div>
+      <button class="modal-close" id="opsRecordsClose" type="button" aria-label="关闭">×</button>
+    </div>
+    <div class="modal-body" id="opsRecordsList"></div>
+  </div>
+</div>
+
 <script src="/crawl.js"></script>
 <script src="/gen.js"></script>
 <script src="/tasks.js"></script>
@@ -1057,6 +1230,11 @@
 <script src="/analysis-templates.js"></script>
 <script src="/reports.js"></script>
 <script src="/lake.js"></script>
+<script src="/multi-select.js"></script>
+<script src="/ops.js"></script>
+<script src="/cockpit.js"></script>
+<script src="/report.js"></script>
+<script src="/schools.js"></script>
 <script src="/ask.js"></script>
 <script src="/app.js"></script>
 </body>

+ 110 - 0
public/multi-select.js

@@ -0,0 +1,110 @@
+'use strict';
+
+// 通用多选下拉(字段 / 店铺 / 学校共用):按钮 + 搜索 + 复选列表 + 全选/清空
+// 用法:window.createMultiSelect(rootEl, { label, items:[{key,label,hint}], selected, allowEmpty, emptyLabel, onChange })
+(function () {
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function createMultiSelect(root, { label, items, selected, allowEmpty, emptyLabel, onChange }) {
+    const sel = new Set(selected || []);
+    let keyword = '';
+    root.innerHTML = `<button type="button" class="ops-multi-btn" aria-expanded="false"></button>
+      <div class="ops-multi-menu" hidden>
+        <input type="text" class="ops-multi-search" placeholder="搜索${esc(label)}">
+        <div class="ops-multi-list"></div>
+        <div class="ops-multi-foot"><button type="button" data-all>全选</button><button type="button" data-none>清空</button></div>
+      </div>`;
+    const btn = root.querySelector('.ops-multi-btn');
+    const menu = root.querySelector('.ops-multi-menu');
+    const list = root.querySelector('.ops-multi-list');
+    const search = root.querySelector('.ops-multi-search');
+
+    function refreshButton() {
+      let text;
+      if (!sel.size) text = `${label} <em>${emptyLabel || '全部'}</em>`;
+      else if (sel.size === 1) {
+        const one = items.find((i) => sel.has(i.key));
+        text = `${label} <em>${esc(one ? one.label : '1')}</em>`;
+      } else text = `${label} <em>${sel.size} 项</em>`;
+      btn.innerHTML = text + '<span class="ops-multi-caret">▾</span>';
+    }
+
+    function renderList() {
+      const kw = keyword.trim().toLowerCase();
+      const filtered = items.filter((i) => !kw
+        || String(i.label).toLowerCase().includes(kw)
+        || String(i.hint || '').toLowerCase().includes(kw));
+      list.innerHTML = filtered.length
+        ? filtered.map((i) => `<label class="ops-multi-item">
+            <input type="checkbox" value="${esc(i.key)}"${sel.has(i.key) ? ' checked' : ''}>
+            <span>${esc(i.label)}</span>${i.hint ? `<em>${esc(i.hint)}</em>` : ''}
+          </label>`).join('')
+        : '<div class="ops-multi-empty">没有匹配项</div>';
+      list.querySelectorAll('input[type="checkbox"]').forEach((input) => {
+        input.addEventListener('change', () => {
+          if (input.checked) sel.add(input.value);
+          else sel.delete(input.value);
+          if (!allowEmpty && !sel.size) {
+            input.checked = true;
+            sel.add(input.value);
+            return;
+          }
+          refreshButton();
+          onChange([...sel]);
+        });
+      });
+    }
+
+    btn.addEventListener('click', (e) => {
+      e.stopPropagation();
+      const willOpen = menu.hidden;
+      document.querySelectorAll('.ops-multi-menu').forEach((m) => { m.hidden = true; });
+      menu.hidden = !willOpen;
+      btn.setAttribute('aria-expanded', String(willOpen));
+      if (willOpen) { renderList(); search.focus(); }
+    });
+    search.addEventListener('input', () => { keyword = search.value; renderList(); });
+    root.querySelector('[data-all]').addEventListener('click', () => {
+      items.forEach((i) => sel.add(i.key));
+      refreshButton();
+      renderList();
+      onChange([...sel]);
+    });
+    root.querySelector('[data-none]').addEventListener('click', () => {
+      sel.clear();
+      if (!allowEmpty && items[0]) sel.add(items[0].key);
+      refreshButton();
+      renderList();
+      onChange([...sel]);
+    });
+    refreshButton();
+    return { refresh: () => { refreshButton(); renderList(); } };
+  }
+
+  let dismissBound = false;
+  function bindMultiSelectDismiss() {
+    if (dismissBound) return;
+    dismissBound = true;
+    document.addEventListener('click', (event) => {
+      document.querySelectorAll('.ops-multi').forEach((root) => {
+        const menu = root.querySelector('.ops-multi-menu');
+        if (!menu || menu.hidden) return;
+        if (root.contains(event.target)) return;
+        menu.hidden = true;
+        const btn = root.querySelector('.ops-multi-btn');
+        if (btn) btn.setAttribute('aria-expanded', 'false');
+      });
+    });
+    document.addEventListener('keydown', (event) => {
+      if (event.key !== 'Escape') return;
+      document.querySelectorAll('.ops-multi-menu').forEach((menu) => { menu.hidden = true; });
+    });
+  }
+
+  window.createMultiSelect = createMultiSelect;
+  window.bindMultiSelectDismiss = bindMultiSelectDismiss;
+})();

+ 882 - 0
public/ops.js

@@ -0,0 +1,882 @@
+'use strict';
+
+// 店铺运营页:上传人工统计表 → 按学校/店铺/日期看指标矩阵或卡片。
+// 数据来自 /api/ops/*;原件存服务端存储(默认本地 SQLite),页面只做展示与上传。
+(function () {
+  const $ = (id) => document.getElementById(id);
+
+  const DEFAULT_FIELDS = ['order_cnt', 'revenue', 'exposure', 'click_rate'];
+
+  function loadJSON(key, fallback) {
+    try {
+      const raw = localStorage.getItem(key);
+      if (!raw) return fallback;
+      const value = JSON.parse(raw);
+      return Array.isArray(value) && value.length ? value : fallback;
+    } catch {
+      return fallback;
+    }
+  }
+
+  const state = {
+    inited: false,
+    loading: false,
+    summary: null,
+    data: null,
+    catalog: [],
+    schools: null, // null = 全选
+    shops: null, // null = 全部店铺
+    fields: loadJSON('ops.fields', DEFAULT_FIELDS.slice()),
+    view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
+    sortKey: null,
+    sortDir: 'desc',
+    from: '',
+    to: '',
+    upload: { files: [], busy: false },
+  };
+
+  function fieldByKey(key) {
+    return state.catalog.find((f) => f.key === key) || { key, label: key, type: 'int' };
+  }
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  /** 日期加减(YYYYMMDD),用于默认「最近 N 天」 */
+  function shiftYmd(ymd, days) {
+    const d = new Date(Date.UTC(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)) + days));
+    return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
+  }
+
+  function ymdToInput(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
+  }
+
+  function inputToYmd(value) {
+    return String(value || '').replace(/-/g, '');
+  }
+
+  function shortDate(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}.${Number(s.slice(6, 8))}` : s;
+  }
+
+  function fmtValue(value, type) {
+    if (value === null || value === undefined || value === '') return '–';
+    if (type === 'text') return String(value);
+    const n = Number(value);
+    if (!Number.isFinite(n)) return String(value);
+    if (type === 'rate') return (n * 100).toFixed(1) + '%';
+    if (type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+    if (type === 'int') return n.toLocaleString('zh-CN');
+    return String(value);
+  }
+
+  function selectedSchools() {
+    if (!state.summary) return [];
+    const all = state.summary.schools.map((s) => s.name);
+    return state.schools === null ? all : all.filter((name) => state.schools.has(name));
+  }
+
+  async function api(url, options) {
+    const res = await fetch(url, options);
+    const isJson = /application\/json/.test(res.headers.get('content-type') || '');
+    const body = isJson ? await res.json().catch(() => null) : null;
+    return { res, body };
+  }
+
+  // ---------- 概览 / 数据 ----------
+
+  async function loadSummary() {
+    const { body } = await api('/api/ops/summary');
+    state.summary = body || { enabled: false, reason: 'storage-unreachable' };
+    if (state.summary.enabled && state.summary.coverage) {
+      // 默认只看最近 2 天;数据不足 2 天时从最早一天开始
+      if (!state.to) state.to = state.summary.coverage.to;
+      if (!state.from) {
+        const recent = shiftYmd(state.to, -1);
+        state.from = recent < state.summary.coverage.from ? state.summary.coverage.from : recent;
+      }
+    }
+    return state.summary;
+  }
+
+  async function loadRows() {
+    if (!state.summary || !state.summary.enabled) return;
+    if (state.schools !== null && state.schools.size === 0) {
+      state.data = null;
+      state.loading = false;
+      renderBody();
+      return;
+    }
+    state.loading = true;
+    renderBody();
+    const params = new URLSearchParams();
+    if (state.from) params.set('from', state.from);
+    if (state.to) params.set('to', state.to);
+    const schools = state.schools === null ? [] : [...state.schools];
+    if (schools.length && schools.length !== (state.summary.schools || []).length) params.set('schools', schools.join(','));
+    if (state.shops !== null && state.shops.size) params.set('shops', [...state.shops].join(','));
+    if (state.fields.length) params.set('fields', state.fields.join(','));
+    const { res, body } = await api('/api/ops/rows?' + params.toString());
+    state.loading = false;
+    if (!res.ok) {
+      state.data = null;
+      state.error = (body && (body.detail || body.error)) || `HTTP ${res.status}`;
+    } else {
+      state.data = body;
+      state.error = null;
+    }
+    renderBody();
+  }
+
+  // ---------- 工具条 ----------
+
+  function renderBar() {
+    const bar = $('opsBar');
+    if (!bar) return;
+    const summary = state.summary;
+    if (!summary || !summary.enabled || !matrixCount(summary)) {
+      bar.innerHTML = '';
+      return;
+    }
+    const schools = summary.schools || [];
+    const chosen = state.schools === null ? new Set(schools.map((s) => s.name)) : state.schools;
+    bar.innerHTML = `
+      <div class="ops-bar">
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">学校</span>
+          <div class="ops-chips" id="opsSchoolChips">
+            ${schools.map((s) => `<button type="button" class="ops-chip${chosen.has(s.name) ? ' on' : ''}" data-school="${esc(s.name)}">${esc(s.name)}<em>${s.shopCount}</em></button>`).join('')}
+          </div>
+        </div>
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">日期</span>
+          <input type="date" class="ops-input" id="opsFrom" value="${ymdToInput(state.from)}">
+          <span class="ops-tilde">~</span>
+          <input type="date" class="ops-input" id="opsTo" value="${ymdToInput(state.to)}">
+        </div>
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">字段</span>
+          <div class="ops-multi" id="opsFieldPicker"></div>
+        </div>
+        <div class="ops-bar-group">
+          <span class="ops-bar-label">店铺</span>
+          <div class="ops-multi" id="opsShopPicker"></div>
+        </div>
+      </div>`;
+    renderHeaderTools();
+
+    bar.querySelectorAll('[data-school]').forEach((btn) => {
+      btn.addEventListener('click', () => {
+        const name = btn.dataset.school;
+        const current = state.schools === null ? new Set(schools.map((s) => s.name)) : new Set(state.schools);
+        if (current.has(name)) current.delete(name); else current.add(name);
+        state.schools = current.size === schools.length ? null : current;
+        if (!current.size) state.schools = new Set();
+        renderBar();
+        loadRows();
+      });
+    });
+    $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
+    $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
+
+    window.createMultiSelect($('opsFieldPicker'), {
+      label: '字段',
+      items: state.catalog.map((f) => ({ key: f.key, label: f.label, hint: f.type === 'text' ? '文本' : '' })),
+      selected: state.fields,
+      allowEmpty: false,
+      onChange: (keys) => {
+        state.fields = keys;
+        localStorage.setItem('ops.fields', JSON.stringify(keys));
+        loadRows();
+      },
+    });
+
+    const shopItems = (summary.shops || []).map((shop) => ({
+      key: String(shop.key || shop.shopId || ''),
+      label: shop.shopName || shop.key,
+      hint: shop.school || '',
+    })).filter((i) => i.key);
+    window.createMultiSelect($('opsShopPicker'), {
+      label: '店铺',
+      items: shopItems,
+      selected: state.shops ? [...state.shops] : [],
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => {
+        state.shops = keys.length ? new Set(keys) : null;
+        loadRows();
+      },
+    });
+  }
+
+  function matrixCount(summary) {
+    if (!summary) return 0;
+    return Number(summary.matrixRowCount != null ? summary.matrixRowCount : summary.rowCount) || 0;
+  }
+
+  function renderHeaderTools() {
+    const el = $('opsHeaderTools');
+    if (!el) return;
+    const summary = state.summary;
+    if (!summary || !summary.enabled || !matrixCount(summary)) {
+      el.innerHTML = '';
+      return;
+    }
+    el.innerHTML = `<div class="ops-seg" id="opsViewSeg">
+        <button type="button" data-mode="table" class="${state.view === 'table' ? 'on' : ''}">报表</button>
+        <button type="button" data-mode="cards" class="${state.view === 'cards' ? 'on' : ''}">卡片</button>
+      </div>
+      <button type="button" class="btn btn-ghost btn-small" id="opsExport">导出 CSV</button>`;
+    el.querySelectorAll('#opsViewSeg [data-mode]').forEach((btn) => {
+      btn.addEventListener('click', () => {
+        state.view = btn.dataset.mode;
+        localStorage.setItem('ops.view', state.view);
+        renderHeaderTools();
+        renderBody();
+      });
+    });
+    $('opsExport').addEventListener('click', () => {
+      const params = new URLSearchParams();
+      if (state.from) params.set('from', state.from);
+      if (state.to) params.set('to', state.to);
+      if (state.schools !== null && state.schools.size) params.set('schools', [...state.schools].join(','));
+      if (state.fields.length) params.set('fields', state.fields.join(','));
+      window.open('/api/ops/export?' + params.toString(), '_blank');
+    });
+  }
+
+  // ---------- 内容区 ----------
+
+  function renderBody() {
+    const body = $('opsBody');
+    if (!body) return;
+    const summary = state.summary;
+    if (!summary) {
+      body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
+      return;
+    }
+    if (!summary.enabled) {
+      const reasonText = {
+        'storage-not-configured': '未配置存储(config.json → storage)',
+        'storage-unreachable': '存储连接失败',
+        'pg-not-installed': '未安装 pg 依赖',
+        'sqlite-unavailable': '当前 Node 版本不支持内置 SQLite(需要 22.5+)',
+      }[summary.reason] || '存储不可用';
+      body.innerHTML = `
+        <div class="empty-hint ops-empty">
+          <p>店铺运营数据暂不可用</p>
+          <small>${esc(reasonText)}${summary.detail ? ':' + esc(summary.detail) : ''}</small>
+          <button type="button" class="btn btn-ghost btn-small" id="opsRetry" style="margin-top:14px">重试</button>
+        </div>`;
+      $('opsRetry').addEventListener('click', () => init(true));
+      return;
+    }
+    const matrixRows = matrixCount(summary);
+    if (!matrixRows) {
+      const platformRows = Number(summary.platformRowCount) || 0;
+      body.innerHTML = platformRows > 0
+        ? `<div class="empty-hint ops-empty">
+            <p>已导入 ${platformRows} 行平台明细</p>
+            <small>平台全字段数据已入库(可在「导入记录」查看原件与行数);<br>上报人工统计表后,这里会显示指标矩阵</small>
+            <button type="button" class="btn btn-primary btn-small" id="opsEmptyUpload" style="margin-top:14px">上传文件</button>
+          </div>`
+        : `<div class="empty-hint ops-empty">
+            <p>还没有店铺运营数据</p>
+            <small>上传商家后台导出的人工统计表(.xlsx),解析后按学校 / 店铺 / 日期查看</small>
+            <button type="button" class="btn btn-primary btn-small" id="opsEmptyUpload" style="margin-top:14px">上传文件</button>
+          </div>`;
+      $('opsEmptyUpload').addEventListener('click', openUpload);
+      return;
+    }
+    if (state.error) {
+      body.innerHTML = `<div class="empty-hint ops-empty"><p>数据加载失败</p><small>${esc(state.error)}</small>
+        <button type="button" class="btn btn-ghost btn-small" id="opsReload" style="margin-top:14px">重试</button></div>`;
+      $('opsReload').addEventListener('click', loadRows);
+      return;
+    }
+    if (state.loading && !state.data) {
+      body.innerHTML = '<div class="empty-hint"><p>正在加载…</p></div>';
+      return;
+    }
+    if (state.schools !== null && state.schools.size === 0) {
+      body.innerHTML = '<div class="empty-hint ops-empty"><p>请至少选择一个学校</p></div>';
+      return;
+    }
+    if (!state.data) return;
+    body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
+    if (state.view === 'table') { bindRowHover(); bindSort(); }
+  }
+
+  function cellValue(shop, field, date) {
+    const series = shop.series[field.key] || {};
+    return Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
+  }
+
+  function metricCell(shop, field, date) {
+    const value = cellValue(shop, field, date);
+    const quality = (shop.quality || {})[date];
+    const warn = quality && quality.length
+      ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
+      : '';
+    if (field.type === 'text') {
+      return `<td class="ops-text-cell${value == null ? ' ops-null' : ''}">${fmtValue(value, field.type)}${warn}</td>`;
+    }
+    return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, field.type)}${warn}</td>`;
+  }
+
+  /** 学校小计:数量/金额求和,比率按曝光加权,文本取第一个非空 */
+  function subtotalValue(group, field, date) {
+    if (field.type === 'text') {
+      for (const shop of group.shops) {
+        const v = cellValue(shop, field, date);
+        if (v !== null) return v;
+      }
+      return null;
+    }
+    if (field.type === 'rate') {
+      let weight = 0;
+      let acc = 0;
+      for (const shop of group.shops) {
+        const exposure = cellValue(shop, { key: 'exposure' }, date);
+        const rate = cellValue(shop, field, date);
+        if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
+          weight += exposure;
+          acc += rate * exposure;
+        }
+      }
+      return weight > 0 ? acc / weight : null;
+    }
+    let sum = 0;
+    let seen = false;
+    for (const shop of group.shops) {
+      const v = cellValue(shop, field, date);
+      if (typeof v === 'number') { sum += v; seen = true; }
+    }
+    return seen ? sum : null;
+  }
+
+  function activeFields(data) {
+    if (data && Array.isArray(data.fields) && data.fields.length) return data.fields;
+    return state.fields.map((key) => fieldByKey(key));
+  }
+
+  /** 门店在某字段上的区间汇总:金额/数量求和,比率按曝光加权 */
+  function fieldAggregate(shop, field, dates) {
+    if (field.type === 'rate') {
+      let num = 0;
+      let den = 0;
+      for (const d of dates) {
+        const v = cellValue(shop, field, d);
+        if (v == null) continue;
+        const weight = Number((shop.series && shop.series.exposure && shop.series.exposure[d]) || 1) || 1;
+        num += v * weight;
+        den += weight;
+      }
+      return den ? num / den : null;
+    }
+    let sum = 0;
+    let has = false;
+    for (const d of dates) {
+      const v = cellValue(shop, field, d);
+      if (typeof v === 'number') { sum += v; has = true; }
+    }
+    return has ? sum : null;
+  }
+
+  /** 按当前排序列排序:门店在各自学校内排序,学校按小计排序 */
+  function sortGroups(groups, fields, dates) {
+    if (!state.sortKey) return groups;
+    const field = fields.find((f) => f.key === state.sortKey);
+    if (!field || field.type === 'text') return groups;
+    const dir = state.sortDir === 'asc' ? 1 : -1;
+    const cmp = (a, b) => {
+      if (a == null && b == null) return 0;
+      if (a == null) return 1;
+      if (b == null) return -1;
+      return dir * (a - b);
+    };
+    const groupTotal = (group) => group.shops.reduce((acc, shop) => {
+      const v = fieldAggregate(shop, field, dates);
+      return v == null ? acc : acc + v;
+    }, 0);
+    return groups
+      .map((group) => ({
+        ...group,
+        shops: group.shops.slice().sort((a, b) => cmp(fieldAggregate(a, field, dates), fieldAggregate(b, field, dates))),
+      }))
+      .sort((a, b) => cmp(groupTotal(a), groupTotal(b)));
+  }
+
+  function renderTables(data) {
+    const fields = activeFields(data);
+    if (!fields.length) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
+    const dates = data.dates || [];
+    const colCount = fields.length + 2;
+
+    const head = `<tr>
+      <th class="ops-shop-col">店铺</th>
+      <th class="ops-date-col">日期</th>
+      ${fields.map((f) => {
+        const sortable = f.type !== 'text';
+        const active = state.sortKey === f.key;
+        const arrow = active ? (state.sortDir === 'asc' ? '↑' : '↓') : '';
+        return `<th class="ops-metric-col ${f.type === 'text' ? 'ops-col-text' : 'ops-col-num'}${sortable ? ' ops-sortable' : ''}${active ? ' ops-sorted' : ''}"${sortable ? ` data-sort="${esc(f.key)}"` : ''} title="${esc(f.label)}${sortable ? '(点击排序)' : ''}">${esc(f.label)}${arrow ? `<span class="ops-sort-arrow">${arrow}</span>` : ''}</th>`;
+      }).join('')}
+    </tr>`;
+
+
+    const groups = sortGroups(data.groups, fields, dates);
+
+    const body = groups.map((group, groupIdx) => {
+      const schoolRow = `<tr class="ops-school-row"><td colspan="${colCount}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
+
+      const shopRows = group.shops.map((shop, shopIdx) => {
+        const shopDates = dates.filter((d) => fields.some((f) => cellValue(shop, f, d) !== null));
+        const useDates = shopDates.length ? shopDates : dates.slice(0, 1);
+        const blockKey = `b${groupIdx}-${shopIdx}`;
+        const alt = shopIdx % 2 === 1 ? ' ops-block-alt' : '';
+        return useDates.map((date, i) => `<tr data-block="${blockKey}" class="ops-shop-row${i === 0 ? ' ops-block-start' : ''}${alt}">
+          ${i === 0 ? `<td class="ops-shop-cell" rowspan="${useDates.length}" title="${esc(shop.shopName)}"><span class="ops-shop-name">${esc(shop.shopName)}</span>${
+            shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
+          }</td>` : ''}
+          <td class="ops-date-cell">${shortDate(date)}</td>
+          ${fields.map((f) => metricCell(shop, f, date)).join('')}
+        </tr>`).join('');
+      }).join('');
+
+      const subtotalDates = dates.filter((d) => group.shops.some((shop) => fields.some((f) => cellValue(shop, f, d) !== null)));
+      const subtotalRows = subtotalDates.map((date) => `<tr class="ops-subtotal-row">
+        <td class="ops-subtotal-label" colspan="2">${esc(group.school)} · 小计 · ${shortDate(date)}</td>
+        ${fields.map((f) => {
+          const v = subtotalValue(group, f, date);
+          return `<td class="${f.type === 'text' ? 'ops-text-cell' : 'ops-num'} ops-subtotal">${v == null ? '–' : fmtValue(v, f.type)}</td>`;
+        }).join('')}
+      </tr>`).join('');
+
+      return schoolRow + shopRows + subtotalRows;
+    }).join('');
+
+    const shopCount = groups.reduce((n, g) => n + g.shops.length, 0);
+    return `<section class="ops-block">
+      <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${fields.length} 个字段</span></div>
+      <div class="ops-table-wrap">
+        <table class="ops-table ops-table-wide" style="min-width: ${240 + 72 + fields.length * 104}px">
+          <thead>${head}</thead>
+          <tbody>${body}</tbody>
+        </table>
+      </div>
+    </section>`;
+  }
+
+  /** 悬停时整块高亮(同一店铺的多天一起亮),避免分不清行属于哪家店 */
+  function bindSort() {
+    document.querySelectorAll('.ops-table-wide th[data-sort]').forEach((th) => {
+      if (th.dataset.sortBound === '1') return;
+      th.dataset.sortBound = '1';
+      th.addEventListener('click', () => {
+        const key = th.dataset.sort;
+        if (state.sortKey === key) state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc';
+        else { state.sortKey = key; state.sortDir = 'desc'; }
+        renderBody();
+      });
+    });
+  }
+
+  function bindRowHover() {
+    document.querySelectorAll('.ops-table-wide tbody').forEach((tbody) => {
+      if (tbody.dataset.hoverBound === '1') return;
+      tbody.dataset.hoverBound = '1';
+      const clear = () => tbody.querySelectorAll('tr.ops-hover').forEach((tr) => tr.classList.remove('ops-hover'));
+      tbody.addEventListener('mouseover', (e) => {
+        const tr = e.target.closest('tr[data-block]');
+        if (!tr) return;
+        if (tr.classList.contains('ops-hover')) return;
+        clear();
+        tbody.querySelectorAll(`tr[data-block="${tr.dataset.block}"]`).forEach((row) => row.classList.add('ops-hover'));
+      });
+      tbody.addEventListener('mouseleave', clear);
+    });
+  }
+
+  function sparkline(shop, field, dates) {
+    const values = dates.map((d) => {
+      const v = (shop.series[field.key] || {})[d];
+      return typeof v === 'number' ? v : null;
+    });
+    const nums = values.filter((v) => v != null);
+    if (nums.length < 2) return '';
+    const min = Math.min(...nums);
+    const max = Math.max(...nums);
+    const span = max - min || 1;
+    const w = 160;
+    const h = 34;
+    const step = w / Math.max(values.length - 1, 1);
+    const points = [];
+    values.forEach((v, i) => {
+      if (v == null) return;
+      const x = (i * step).toFixed(1);
+      const y = (h - ((v - min) / span) * (h - 6) - 3).toFixed(1);
+      points.push(`${x},${y}`);
+    });
+    return `<svg class="ops-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${points.join(' ')}" /></svg>`;
+  }
+
+  function renderCards(data) {
+    const dates = data.dates || [];
+    const fields = activeFields(data);
+    const first = fields[0];
+    if (!first) return '<div class="empty-hint"><p>请至少选择一个字段</p></div>';
+    return `<div class="ops-cards">${data.groups.map((group) => `
+      <section class="ops-card-group">
+        <h3>${esc(group.school)}</h3>
+        <div class="ops-card-grid">
+          ${group.shops.map((shop) => `
+            <article class="ops-card">
+              <div class="ops-card-head">
+                <span class="ops-card-name" title="${esc(shop.shopName)}">${esc(shop.shopName)}</span>
+                ${shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted">${esc(shop.status)}</span>` : ''}
+              </div>
+              <div class="ops-card-metrics">
+                ${fields.map((f) => `<div class="ops-card-metric"><span>${esc(f.label)}</span><b>${fmtValue(shop.totals ? shop.totals[f.key] : null, f.type)}</b></div>`).join('')}
+              </div>
+              ${sparkline(shop, first, dates)}
+            </article>`).join('')}
+        </div>
+      </section>`).join('')}</div>`;
+  }
+
+  // ---------- 上传 ----------
+
+  async function refreshSchoolOptions() {
+    const select = $('opsSchool');
+    if (!select) return;
+    // 已登记学校(校区地图里的学校注册表,含内置)
+    const registered = [];
+    try {
+      const { res, body } = await api('/api/schools');
+      if (res.ok) {
+        for (const school of (body && body.schools) || []) {
+          if (!school || !school.name) continue;
+          registered.push({
+            name: school.name,
+            hint: [school.province, school.city].filter(Boolean).join(' · '),
+          });
+        }
+      }
+    } catch { /* 注册表取不到时回落到运营数据里的学校 */ }
+
+    // 运营数据中出现过、但还没登记的学校
+    const registeredNames = new Set(registered.map((x) => x.name));
+    const fromOps = ((state.summary && state.summary.schools) || [])
+      .map((x) => x.name)
+      .filter((name) => name && !registeredNames.has(name));
+
+    select.innerHTML = '<option value="">自动(读取表格里的「学校名称」列)</option>'
+      + (registered.length
+        ? `<optgroup label="已登记学校">${registered.map((x) => `<option value="${esc(x.name)}">${esc(x.name)}${x.hint ? '(' + esc(x.hint) + ')' : ''}</option>`).join('')}</optgroup>`
+        : '')
+      + (fromOps.length
+        ? `<optgroup label="运营数据中的学校">${fromOps.map((name) => `<option value="${esc(name)}">${esc(name)}</option>`).join('')}</optgroup>`
+        : '')
+      + '<option value="__custom">手动输入…</option>';
+
+    select.value = '';
+    const custom = $('opsSchoolCustom');
+    if (custom) {
+      custom.value = '';
+      custom.hidden = true;
+    }
+  }
+
+  function selectedSchool() {
+    const select = $('opsSchool');
+    if (!select) return '';
+    if (select.value === '__custom') return String(($('opsSchoolCustom') || {}).value || '').trim();
+    return select.value;
+  }
+
+  function fileKey(f) {
+    return f.name + '|' + f.size + '|' + (f.lastModified || 0);
+  }
+
+  function addFiles(fileList) {
+    for (const f of Array.from(fileList || [])) {
+      if (!/\.xlsx$/i.test(f.name)) continue;
+      const key = fileKey(f);
+      if (state.upload.files.some((x) => fileKey(x.file) === key)) continue;
+      state.upload.files.push({ file: f, status: 'pending', message: '' });
+    }
+    renderFileList();
+  }
+
+  function renderFileList() {
+    const box = $('opsFileList');
+    if (!box) return;
+    if (!state.upload.files.length) {
+      box.innerHTML = '';
+      return;
+    }
+    box.innerHTML = state.upload.files.map((item, i) => {
+      const cls = item.status === 'error' ? 'ops-error'
+        : item.status === 'done' ? 'ops-done-text'
+          : item.status === 'duplicate' ? 'ops-muted' : '';
+      const msg = item.message ? `<em class="${cls}">${esc(item.message)}</em>` : '';
+      const removable = item.status !== 'uploading';
+      return `<div class="ops-file-item">
+        <span class="ops-file-name2" title="${esc(item.file.name)}">${esc(item.file.name)}</span>
+        <span class="ops-file-size">${(item.file.size / 1024).toFixed(0)} KB</span>
+        ${msg}
+        ${removable ? `<button type="button" class="ops-file-del" data-idx="${i}" title="移除">×</button>` : ''}
+      </div>`;
+    }).join('');
+    box.querySelectorAll('[data-idx]').forEach((btn) => {
+      btn.addEventListener('click', () => {
+        const i = Number(btn.dataset.idx);
+        if (state.upload.files[i] && state.upload.files[i].status === 'uploading') return;
+        state.upload.files.splice(i, 1);
+        renderFileList();
+      });
+    });
+  }
+
+  async function openUpload() {
+    const modal = $('opsUploadModal');
+    if (!modal) return;
+    state.upload.files = [];
+    renderFileList();
+    $('opsUploadResult').innerHTML = '';
+    $('opsYear').value = String(new Date().getFullYear());
+    await refreshSchoolOptions();
+    if ($('opsForce')) $('opsForce').checked = false;
+    $('opsUploadStart').disabled = false;
+    modal.hidden = false;
+  }
+
+  function closeUpload() {
+    const modal = $('opsUploadModal');
+    if (modal) modal.hidden = true;
+  }
+
+  function readFileBase64(file) {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => {
+        const text = String(reader.result || '');
+        resolve(text.slice(text.indexOf(',') + 1));
+      };
+      reader.onerror = () => reject(reader.error || new Error('读取文件失败'));
+      reader.readAsDataURL(file);
+    });
+  }
+
+  async function doUpload(forceOverride) {
+    const items = state.upload.files;
+    const result = $('opsUploadResult');
+    if (!items.length) {
+      result.innerHTML = '<span class="ops-error">请先选择 .xlsx 文件(可一次选多个)</span>';
+      return;
+    }
+    if (items.length > 30) {
+      result.innerHTML = '<span class="ops-error">一次最多 30 个文件</span>';
+      return;
+    }
+    if (state.upload.busy) return;
+    state.upload.busy = true;
+
+    const year = Number($('opsYear').value) || new Date().getFullYear();
+    const school = selectedSchool();
+    const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
+    $('opsUploadStart').disabled = true;
+    result.innerHTML = '<span class="ops-muted">正在逐个解析并入库…</span>';
+
+    let okCount = 0;
+    let skipCount = 0;
+    let failCount = 0;
+    for (const item of items) {
+      if (item.status === 'done') continue;
+      item.status = 'uploading';
+      item.message = '导入中…';
+      renderFileList();
+      try {
+        const base64 = await readFileBase64(item.file);
+        const { res, body } = await api('/api/ops/upload', {
+          method: 'POST',
+          headers: { 'Content-Type': 'application/json' },
+          body: JSON.stringify({ fileName: item.file.name, contentBase64: base64, year, school, force }),
+        });
+        if (res.status === 409) {
+          item.status = 'duplicate';
+          item.message = '已导入过(跳过)';
+          skipCount += 1;
+        } else if (!res.ok) {
+          const detail = body && (body.detail ? body.error + ':' + body.detail : body.error);
+          item.status = 'error';
+          item.message = detail || ('失败 HTTP ' + res.status);
+          failCount += 1;
+        } else {
+          const r = body.report || {};
+          item.status = 'done';
+          if (r.kind === 'platform-export') {
+            const parts = ['新增 ' + r.newRows + ' 行'];
+            if (r.identicalRows) parts.push('已存在 ' + r.identicalRows + ' 行');
+            if (r.conflictRows) parts.push('冲突 ' + r.conflictRows + ' 行');
+            item.message = parts.join(' · ');
+          } else {
+            item.message = r.rows + ' 行 · ' + r.shops + ' 家';
+          }
+          okCount += 1;
+        }
+      } catch (e) {
+        item.status = 'error';
+        item.message = e.message || String(e);
+        failCount += 1;
+      }
+      renderFileList();
+    }
+
+    const parts = ['成功 ' + okCount + ' 个'];
+    if (skipCount) parts.push('跳过 ' + skipCount + ' 个(已导入过)');
+    if (failCount) parts.push('失败 ' + failCount + ' 个');
+    result.innerHTML = `<div class="${failCount ? 'ops-error' : 'ops-ok'}">${parts.join(',')}${
+      skipCount && !force ? '<br><small>已存在的文件可勾选下方「覆盖已导入的文件」后重新导入</small>' : ''
+    }</div>`;
+    $('opsUploadStart').disabled = false;
+    state.upload.busy = false;
+    if (okCount) {
+      state.from = '';
+      state.to = '';
+      state.schools = null;
+      await init(true);
+      // 通知其它页面(校区地图 / 经营驾驶舱)重新拉取数据
+      window.dispatchEvent(new CustomEvent('ops:data-changed'));
+    }
+  }
+
+  // ---------- 导入记录 ----------
+
+  async function openRecords() {
+    const modal = $('opsRecordsModal');
+    if (!modal) return;
+    modal.hidden = false;
+    const list = $('opsRecordsList');
+    list.innerHTML = '<div class="ops-muted" style="padding:16px">加载中…</div>';
+    const { res, body } = await api('/api/ops/uploads');
+    if (!res.ok) {
+      list.innerHTML = `<div class="ops-error" style="padding:16px">${esc((body && (body.detail || body.error)) || ('HTTP ' + res.status))}</div>`;
+      return;
+    }
+    const uploads = (body && body.uploads) || [];
+    if (!uploads.length) {
+      list.innerHTML = '<div class="ops-muted" style="padding:16px">还没有导入记录</div>';
+      return;
+    }
+    list.innerHTML = `<table class="data-table">
+      <thead><tr><th>文件名</th><th>上传时间</th><th>行数</th><th>门店</th><th>警告</th><th class="col-actions">操作</th></tr></thead>
+      <tbody>${uploads.map((u) => `<tr>
+        <td>${esc(u.originalName)}</td>
+        <td>${esc(new Date(u.uploadedAt).toLocaleString('zh-CN'))}</td>
+        <td>${u.rows || 0}</td>
+        <td>${u.shops || 0}</td>
+        <td>${u.warningCount || 0}</td>
+        <td class="col-actions">
+          <a class="btn btn-ghost btn-small" href="/api/ops/uploads/${esc(u.id)}/file">下载原件</a>
+          <button type="button" class="btn btn-ghost btn-small" data-reparse="${esc(u.id)}">重新解析</button>
+        </td>
+      </tr>`).join('')}</tbody>
+    </table>`;
+    list.querySelectorAll('[data-reparse]').forEach((btn) => {
+      btn.addEventListener('click', async () => {
+        btn.disabled = true;
+        btn.textContent = '解析中…';
+        const { res, body: rep } = await api(`/api/ops/uploads/${btn.dataset.reparse}/reparse`, { method: 'POST' });
+        btn.disabled = false;
+        btn.textContent = '重新解析';
+        if (!res.ok) {
+          alert((rep && (rep.detail || rep.error)) || '重新解析失败');
+          return;
+        }
+        await init(true);
+      });
+    });
+  }
+
+  // ---------- 初始化 ----------
+
+  async function loadCatalog() {
+    const { res, body } = await api('/api/ops/fields');
+    if (!res.ok || !body || !Array.isArray(body.fields)) return;
+    state.catalog = body.fields;
+    const valid = new Set(body.fields.map((f) => f.key));
+    state.fields = state.fields.filter((k) => valid.has(k));
+    if (!state.fields.length) state.fields = (body.defaults || []).filter((k) => valid.has(k));
+    if (!state.fields.length) state.fields = body.fields.slice(0, 4).map((f) => f.key);
+  }
+
+  async function init(force) {
+    if (state.inited && !force) return;
+    state.inited = true;
+    await loadSummary();
+    await loadCatalog();
+    renderBar();
+    renderHeaderTools();
+    renderBody();
+    if (state.summary && state.summary.enabled && matrixCount(state.summary)) await loadRows();
+  }
+
+  let bound = false;
+
+  function bindOnce() {
+    if (bound) return;
+    bound = true;
+    window.bindMultiSelectDismiss();
+    const uploadBtn = $('btnOpsUpload');
+    if (uploadBtn) uploadBtn.addEventListener('click', openUpload);
+    const recordsBtn = $('btnOpsRecords');
+    if (recordsBtn) recordsBtn.addEventListener('click', openRecords);
+    const start = $('opsUploadStart');
+    if (start) start.addEventListener('click', () => doUpload(false));
+    const cancel = $('opsUploadCancel');
+    if (cancel) cancel.addEventListener('click', closeUpload);
+    const closeBtn = $('opsUploadClose');
+    if (closeBtn) closeBtn.addEventListener('click', closeUpload);
+    const schoolSelect = $('opsSchool');
+    if (schoolSelect) {
+      schoolSelect.addEventListener('change', () => {
+        const custom = $('opsSchoolCustom');
+        if (!custom) return;
+        custom.hidden = schoolSelect.value !== '__custom';
+        if (!custom.hidden) custom.focus();
+      });
+    }
+    const file = $('opsFile');
+    if (file) {
+      file.addEventListener('change', () => {
+        addFiles(file.files);
+        file.value = '';
+      });
+    }
+    const drop = $('opsDrop');
+    if (drop) {
+      ['dragover', 'dragenter'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('on'); }));
+      ['dragleave', 'drop'].forEach((ev) => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('on'); }));
+      drop.addEventListener('drop', (e) => {
+        if (!e.dataTransfer || !e.dataTransfer.files) return;
+        addFiles(e.dataTransfer.files);
+      });
+      drop.addEventListener('click', () => { const input = $('opsFile'); if (input) input.click(); });
+    }
+    const recordsClose = $('opsRecordsClose');
+    if (recordsClose) recordsClose.addEventListener('click', () => { $('opsRecordsModal').hidden = true; });
+  }
+
+  window.shanOpsInit = function () {
+    bindOnce();
+    init(false);
+  };
+})();

+ 620 - 0
public/report.js

@@ -0,0 +1,620 @@
+'use strict';
+
+// 经营报告:同一份 Report JSON 渲染「图文报告 / 交互报告」,PDF 走浏览器打印样式。
+// 数据来自 GET /api/ops/report(后端算指标、规则打标,模型只负责文字)。
+(function () {
+  const $ = (id) => document.getElementById(id);
+  const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#c9c2ba', blue: '#7a9cc6', light: '#ffd6c2' };
+
+  const state = {
+    inited: false,
+    loading: false,
+    report: null,
+    from: '',
+    to: '',
+    schools: [],
+    shops: [],
+    charts: {},
+  };
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function moneyText(v) {
+    if (v == null) return '–';
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '–';
+  }
+
+  function kpiText(kpi) {
+    if (kpi.current == null) return '–';
+    if (kpi.unit === '%') return kpi.current + '%';
+    if (kpi.unit === '单' || kpi.unit === '人') return numText(kpi.current);
+    return moneyText(kpi.current);
+  }
+
+  function numText(v) {
+    if (v == null) return '–';
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '–';
+  }
+
+  function deltaText(kpi) {
+    if (kpi.change_pct == null) return '<span class="rp-delta muted">—</span>';
+    const up = kpi.change_pct > 0;
+    const cls = kpi.change_pct === 0 ? 'muted' : up ? 'up' : 'down';
+    const unit = kpi.unit === '%' ? 'pp' : '%';
+    return `<span class="rp-delta ${cls}">${up ? '↑' : '↓'} ${Math.abs(kpi.change_pct).toFixed(1)}${unit}</span>`;
+  }
+
+  async function api(url, options) {
+    const res = await fetch(url, options);
+    const body = await res.json().catch(() => null);
+    return { res, body };
+  }
+
+  // ---------- 渲染 ----------
+
+  function renderShell() {
+    const root = $('reportRoot');
+    if (!root) return;
+    root.innerHTML = `
+      <div class="rp-toolbar">
+        <div class="rp-range">
+          <input type="date" class="ops-input" id="rpFrom">
+          <span class="ops-tilde">~</span>
+          <input type="date" class="ops-input" id="rpTo">
+        </div>
+        <div class="ops-multi" id="rpSchool"></div>
+        <div class="ops-multi" id="rpShop"></div>
+        <div class="rp-actions">
+          <button type="button" class="btn btn-ghost btn-small" id="rpArchive">历史报告</button>
+          <button type="button" class="btn btn-ghost btn-small" id="rpSave" disabled>保存报告</button>
+          <button type="button" class="btn btn-ghost btn-small" id="rpRefresh">重新生成</button>
+          <button type="button" class="btn btn-primary btn-small" id="rpPrint">下载 PDF</button>
+        </div>
+      </div>
+      <article class="rp-page" id="rpPage"></article>
+      <div class="modal-overlay" id="rpHistoryModal" hidden>
+        <div class="modal rp-history-modal">
+          <div class="modal-header">
+            <div class="modal-title"><span>历史报告</span></div>
+            <button class="modal-close" id="rpHistoryClose" type="button" aria-label="关闭">×</button>
+          </div>
+          <div class="modal-body" id="rpHistoryList"></div>
+        </div>
+      </div>`;
+    $('rpFrom').addEventListener('change', (e) => { state.from = String(e.target.value || '').replace(/-/g, ''); load(true); });
+    $('rpTo').addEventListener('change', (e) => { state.to = String(e.target.value || '').replace(/-/g, ''); load(true); });
+    $('rpRefresh').addEventListener('click', () => load(true));
+    $('rpPrint').addEventListener('click', () => window.print());
+    $('rpSave').addEventListener('click', saveReport);
+    $('rpArchive').addEventListener('click', openHistory);
+    $('rpHistoryClose').addEventListener('click', () => { $('rpHistoryModal').hidden = true; });
+  }
+
+  function renderCover(report) {
+    const meta = report.meta || {};
+    return `
+      <header class="rp-cover">
+        <div class="rp-cover-main">
+          <div class="rp-kicker">AI 经营分析报告</div>
+          <h1>${esc(meta.title || '经营分析报告')}</h1>
+          <div class="rp-cover-sub">${esc(meta.organization || '')}${meta.organization ? ' · ' : ''}${esc(meta.period_start)} ~ ${esc(meta.period_end)}</div>
+        </div>
+        <div class="rp-cover-meta">
+          <div><span>统计范围</span><b>${esc(meta.data_scope || '–')}</b></div>
+          <div><span>数据来源</span><b>${esc((meta.source_files || []).join('、') || '平台导出明细')}</b></div>
+          <div><span>生成时间</span><b>${esc(new Date(meta.generated_at).toLocaleString('zh-CN'))}</b></div>
+        </div>
+      </header>`;
+  }
+
+  function renderSummary(report) {
+    const summary = report.executive_summary || {};
+    const dot = { positive: 'ok', neutral: 'na', warning: 'warn', critical: 'bad' };
+    const items = (summary.key_findings || []).map((f) => `
+      <li>
+        <span class="rp-dot ${dot[f.severity] || 'na'}"></span>
+        <div>
+          <b>${esc(f.finding)}</b>
+          <p>${esc(f.interpretation || '')}</p>
+          ${(f.evidence || []).length ? `<div class="rp-evidence">${f.evidence.map((e) => `<code>${esc(e)}</code>`).join('')}</div>` : ''}
+        </div>
+      </li>`).join('');
+    return `
+      <section class="rp-section rp-summary">
+        <h2>管理层摘要</h2>
+        <p class="rp-lead">${esc(summary.overall_assessment || '')}</p>
+        <ul class="rp-findings">${items}</ul>
+      </section>`;
+  }
+
+  function renderKpis(report) {
+    return `
+      <section class="rp-section">
+        <h2>核心指标</h2>
+        <div class="rp-kpis">
+          ${(report.kpis || []).map((kpi) => `
+            <div class="rp-kpi" title="${esc(kpi.definition)}">
+              <div class="rp-kpi-label">${esc(kpi.label)}<span>${esc(kpi.unit === 'CNY' ? '元' : kpi.unit)}</span></div>
+              <div class="rp-kpi-value">${kpiText(kpi)}</div>
+              <div class="rp-kpi-foot">${deltaText(kpi)}<span class="rp-kpi-hint">较上期</span></div>
+            </div>`).join('')}
+        </div>
+        <p class="rp-note">口径:${(report.kpis || []).map((k) => `${k.label}=${k.definition}`).join(';')}</p>
+      </section>`;
+  }
+
+  function chartCard(id, title, subtitle) {
+    return `<figure class="rp-figure">
+      <figcaption><b>${esc(title)}</b><span>${esc(subtitle || '')}</span></figcaption>
+      <div class="rp-chart" id="${id}"></div>
+    </figure>`;
+  }
+
+  function renderSections(report) {
+    const chartById = new Map((report.charts || []).map((c) => [c.id, c]));
+    const tableById = new Map((report.tables || []).map((t) => [t.id, t]));
+    return (report.sections || []).map((section, index) => {
+      if (section.id === 'quality') return '';
+      const charts = (section.chart_ids || []).map((id) => chartById.get(id)).filter(Boolean);
+      const tables = (section.table_ids || []).map((id) => tableById.get(id)).filter(Boolean);
+      return `
+        <section class="rp-section">
+          <h2><em>${String(index + 1).padStart(2, '0')}</em>${esc(section.title)}</h2>
+          ${section.summary ? `<p class="rp-lead">${esc(section.summary)}</p>` : ''}
+          ${(section.paragraphs || []).map((p) => `<p>${esc(p)}</p>`).join('')}
+          ${charts.map((chart) => chartCard(`rpChart_${chart.id}`, chart.title, chart.description)).join('')}
+          ${tables.map((table) => renderTable(table)).join('')}
+        </section>`;
+    }).join('');
+  }
+
+  function renderTable(table) {
+    return `
+      <div class="rp-table-wrap">
+        <table class="rp-table">
+          <thead><tr>${table.columns.map((c) => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>
+          <tbody>${(table.rows || []).map((row) => `<tr>${table.columns.map((c) => {
+            const v = row[c.key];
+            const text = c.key === 'revenue' ? moneyText(v) : c.key === 'share' ? (v == null ? '–' : v + '%') : (v == null ? '–' : esc(v));
+            return `<td>${text}</td>`;
+          }).join('')}</tr>`).join('')}</tbody>
+        </table>
+        ${table.footnote ? `<p class="rp-note">${esc(table.footnote)}</p>` : ''}
+      </div>`;
+  }
+
+  function renderAlerts(report) {
+    const alerts = report.alerts || [];
+    const plan = report.action_plan || [];
+    return `
+      <section class="rp-section">
+        <h2><em>05</em>风险与建议</h2>
+        <div class="rp-alerts">
+          ${alerts.map((a) => `
+            <div class="rp-alert ${esc(a.severity)}">
+              <div class="rp-alert-head"><span class="rp-badge">${esc(a.severity)}</span><b>${esc(a.title)}</b></div>
+              <ul>${(a.evidence || []).map((e) => `<li>${esc(e)}</li>`).join('')}</ul>
+              ${(a.hypotheses || []).length ? `<p class="rp-note">待验证:${a.hypotheses.map(esc).join('、')}</p>` : ''}
+            </div>`).join('') || '<p class="rp-note">本期未触发风险规则。</p>'}
+        </div>
+        <h3 class="rp-h3">行动计划</h3>
+        <table class="rp-table">
+          <thead><tr><th>优先级</th><th>行动</th><th>负责人</th><th>时间窗</th><th>验收指标</th></tr></thead>
+          <tbody>${plan.map((p) => `<tr>
+            <td><span class="rp-badge">${esc(p.priority)}</span></td>
+            <td>${esc(p.action)}</td>
+            <td>${esc(p.owner_role)}</td>
+            <td>${esc(p.time_horizon)}</td>
+            <td>${esc(p.success_metric)}</td>
+          </tr>`).join('')}</tbody>
+        </table>
+      </section>`;
+  }
+
+  function renderQuality(report) {
+    const q = report.data_quality || {};
+    return `
+      <section class="rp-section rp-quality">
+        <h2><em>06</em>数据质量与方法论</h2>
+        <p><b>覆盖范围:</b>${esc(q.coverage || '–')}</p>
+        <p><b>可比性:</b>${esc(q.comparability || '–')}</p>
+        ${(q.warnings || []).length ? `<p><b>提示:</b>${q.warnings.map(esc).join(';')}</p>` : ''}
+        ${(q.missing_metrics || []).length ? `<p><b>缺失指标:</b>${q.missing_metrics.map(esc).join(';')}</p>` : ''}
+        <p><b>方法论:</b></p>
+        <ul class="rp-method">${(report.methodology || []).map((m) => `<li>${esc(m)}</li>`).join('')}</ul>
+      </section>`;
+  }
+
+  function renderReport(report) {
+    const page = $('rpPage');
+    const saveBtn = $('rpSave');
+    if (saveBtn) saveBtn.disabled = false;
+    page.innerHTML = [
+      renderCover(report),
+      renderSummary(report),
+      renderKpis(report),
+      renderSections(report),
+      renderAlerts(report),
+      renderQuality(report),
+      `<footer class="rp-footer">${report.ai && report.ai.source === 'llm' ? `文字由 AI 生成(${esc(report.ai.model || '')})` : '当前为规则版文字'};数值均由后端按统一口径计算。${report.aiError ? ' ' + esc(report.aiError) : ''}</footer>`,
+    ].join('');
+    drawCharts(report);
+  }
+
+  // ---------- 图表 ----------
+
+  function chartBase() {
+    return {
+      animation: true,
+      tooltip: { trigger: 'axis' },
+    };
+  }
+
+  function drawCharts(report) {
+    
+    const byId = new Map((report.charts || []).map((c) => [c.id, c]));
+    Object.values(state.charts).forEach((chart) => chart && chart.dispose());
+    state.charts = {};
+
+    const mount = (chart) => {
+      const el = $(`rpChart_${chart.id}`);
+      if (!el) return null;
+      const instance = echarts.init(el);
+      state.charts[chart.id] = instance;
+      return instance;
+    };
+
+    const trend = byId.get('chart_trend');
+    if (trend) {
+      const chart = mount(trend);
+      chart && chart.setOption({
+        ...chartBase(),
+        grid: { left: 56, right: 48, top: 24, bottom: 30 },
+        legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+        xAxis: { type: 'category', data: trend.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
+        yAxis: [{ type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } }, { type: 'value', splitLine: { show: false } }],
+        series: [
+          { name: '营业额', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: trend.data.map((d) => d.revenue) },
+          { name: '订单', type: 'line', yAxisIndex: 1, symbolSize: 5, lineStyle: { width: 2, color: COLORS.blue }, itemStyle: { color: COLORS.blue }, data: trend.data.map((d) => d.orders) },
+        ],
+      });
+    }
+
+    const rank = byId.get('chart_rank');
+    if (rank) {
+      const rows = rank.data.slice().reverse();
+      const chart = mount(rank);
+      chart && chart.setOption({
+        ...chartBase(),
+        grid: { left: 8, right: 60, top: 10, bottom: 10, containLabel: true },
+        xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
+        yAxis: { type: 'category', data: rows.map((r) => r.shopName), axisTick: { show: false }, axisLine: { show: false }, axisLabel: { width: 140, overflow: 'truncate', fontSize: 11 } },
+        series: [{ type: 'bar', barMaxWidth: 14, itemStyle: { color: COLORS.soft, borderRadius: [0, 3, 3, 0] }, label: { show: true, position: 'right', fontSize: 10, color: '#6f6862' }, data: rows.map((r) => r.revenue) }],
+      });
+    }
+
+    const funnel = byId.get('chart_funnel');
+    if (funnel) {
+      const f = funnel.data;
+      const chart = mount(funnel);
+      chart && chart.setOption({
+        ...chartBase(),
+        series: [{
+          type: 'funnel', left: '10%', right: '10%', top: 12, bottom: 12, minSize: '30%', gap: 3,
+          label: { show: true, position: 'inside', color: '#fff', fontSize: 11, formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}` },
+          itemStyle: { borderWidth: 0 },
+          color: [COLORS.main, '#ff9a70', COLORS.light],
+          data: [{ name: '曝光', value: f.exposure }, { name: '进店', value: f.visit }, { name: '下单', value: f.order }],
+        }],
+      });
+    }
+
+    const structure = byId.get('chart_structure');
+    if (structure) {
+      const chart = mount(structure);
+      chart && chart.setOption({
+        ...chartBase(),
+        grid: { left: 56, right: 20, top: 28, bottom: 30 },
+        legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+        xAxis: { type: 'category', data: structure.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
+        yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
+        series: [
+          { name: '收入', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.main }, data: structure.data.map((d) => d.income) },
+          { name: '支出', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.grey }, data: structure.data.map((d) => d.expense) },
+          { name: '补贴', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.light }, data: structure.data.map((d) => d.subsidy) },
+        ],
+      });
+    }
+  }
+
+  // ---------- 报告中心 ----------
+
+  async function saveReport() {
+    const btn = $('rpSave');
+    btn.disabled = true;
+    btn.textContent = '保存中…';
+    try {
+      const { res, body } = await api('/api/ops/reports', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({
+          from: state.from, to: state.to,
+          schools: state.schools.join(','), shops: state.shops.join(','),
+          report: state.report,
+        }),
+      });
+      if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
+      btn.textContent = '已保存';
+      setTimeout(() => { btn.textContent = '保存报告'; btn.disabled = false; }, 1500);
+    } catch (e) {
+      btn.textContent = '保存报告';
+      btn.disabled = false;
+      alert('保存失败:' + (e.message || e));
+    }
+  }
+
+  async function openHistory() {
+    $('rpHistoryModal').hidden = false;
+    await renderHistory();
+  }
+
+  async function renderHistory() {
+    const list = $('rpHistoryList');
+    list.innerHTML = '<div class="rp-loading" style="padding:24px 0">加载中…</div>';
+    const { res, body } = await api('/api/ops/reports');
+    if (!res.ok) {
+      list.innerHTML = `<div class="rp-loading">读取失败:${esc((body && (body.error || body.detail)) || res.status)}</div>`;
+      return;
+    }
+    const reports = (body && body.reports) || [];
+    if (!reports.length) {
+      list.innerHTML = '<div class="rp-loading" style="padding:24px 0">还没有保存过报告</div>';
+      return;
+    }
+    list.innerHTML = `<table class="rp-table">
+      <thead><tr><th>保存时间</th><th>区间</th><th>学校</th><th>店铺</th><th>来源</th><th class="rp-col-actions">操作</th></tr></thead>
+      <tbody>${reports.map((r) => `<tr>
+        <td>${esc(new Date(r.createdAt).toLocaleString('zh-CN'))}</td>
+        <td>${esc(r.from)} ~ ${esc(r.to)}</td>
+        <td>${(r.schoolNames || []).length ? esc(r.schoolNames.join('、')) : '<span class="rp-muted">全部</span>'}</td>
+        <td title="${esc((r.shopNames || []).join('、'))}">${(r.shopNames || []).length
+          ? esc(r.shopNames.slice(0, 3).join('、')) + (r.shopNames.length > 3 ? ` 等 ${r.shopNames.length} 家` : '')
+          : '<span class="rp-muted">全部</span>'}</td>
+        <td>${r.source === 'llm' ? 'AI 生成' : '规则生成'}</td>
+        <td class="rp-col-actions">
+          <button type="button" class="btn btn-ghost btn-small" data-view="${esc(r.id)}">查看</button>
+          <button type="button" class="btn btn-ghost btn-small" data-pdf="${esc(r.id)}">PDF</button>
+          <button type="button" class="btn btn-ghost btn-small" data-json="${esc(r.id)}">JSON</button>
+          <button type="button" class="btn btn-ghost btn-small" data-del="${esc(r.id)}">删除</button>
+        </td>
+      </tr>`).join('')}</tbody>
+    </table>`;
+    list.querySelectorAll('[data-view]').forEach((btn) => {
+      btn.addEventListener('click', () => loadSaved(btn.dataset.view));
+    });
+    list.querySelectorAll('[data-pdf]').forEach((btn) => {
+      btn.addEventListener('click', () => downloadSavedPdf(btn.dataset.pdf));
+    });
+    list.querySelectorAll('[data-json]').forEach((btn) => {
+      btn.addEventListener('click', () => downloadSavedJson(btn.dataset.json));
+    });
+    list.querySelectorAll('[data-del]').forEach((btn) => {
+      btn.addEventListener('click', async () => {
+        if (!confirm('删除这份历史报告?')) return;
+        btn.disabled = true;
+        await api('/api/ops/reports/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' });
+        renderHistory();
+      });
+    });
+  }
+
+  async function fetchSaved(id) {
+    const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
+    if (!res.ok || !body || !body.report) {
+      alert('读取失败');
+      return null;
+    }
+    return body.report;
+  }
+
+  async function downloadSavedPdf(id) {
+    const report = await fetchSaved(id);
+    if (!report) return;
+    state.report = report;
+    $('rpHistoryModal').hidden = true;
+    renderReport(report);
+    const meta = report.meta || {};
+    const prevTitle = document.title;
+    document.title = `经营分析报告_${String(meta.period_start || '').replace(/-/g, '')}-${String(meta.period_end || '').replace(/-/g, '')}`;
+    setTimeout(() => {
+      window.print();
+      document.title = prevTitle;
+    }, 500);
+  }
+
+  async function downloadSavedJson(id) {
+    const report = await fetchSaved(id);
+    if (!report) return;
+    const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = `经营分析报告_${id}.json`;
+    document.body.appendChild(a);
+    a.click();
+    a.remove();
+    setTimeout(() => URL.revokeObjectURL(url), 1000);
+  }
+
+  async function loadSaved(id) {
+    const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
+    if (!res.ok || !body || !body.report) {
+      alert('读取失败');
+      return;
+    }
+    const report = body.report;
+    state.report = report;
+    const from = String(report.meta.period_start || '').replace(/-/g, '');
+    const to = String(report.meta.period_end || '').replace(/-/g, '');
+    state.from = from;
+    state.to = to;
+    $('rpFrom').value = String(report.meta.period_start || '');
+    $('rpTo').value = String(report.meta.period_end || '');
+    renderReport(report);
+    $('rpHistoryModal').hidden = true;
+  }
+
+  // ---------- 数据 ----------
+
+  function buildParams(force) {
+    const params = new URLSearchParams();
+    if (state.from) params.set('from', state.from);
+    if (state.to) params.set('to', state.to);
+    if (state.schools.length) params.set('schools', state.schools.join(','));
+    if (state.shops.length) params.set('shops', state.shops.join(','));
+    if (force) params.set('ai', '1');
+    return params;
+  }
+
+  /** 先秒开已生成的报告,再在后台让 AI 重写一版(只尝试一次) */
+  async function upgradeToAi(seq) {
+    try {
+      const params = buildParams(false);
+      params.set('ai', '1');
+      const { res, body } = await api('/api/ops/report?' + params.toString());
+      if (!res.ok || !body || !body.report || seq !== state.loadSeq) return;
+      const report = body.report;
+      if (report.ai && report.ai.source === 'llm') {
+        state.report = report;
+        renderReport(report);
+      }
+    } catch { /* 后台升级失败保持规则版 */ }
+  }
+
+  async function load(force) {
+    if (state.loading) { state.pending = true; return; }
+    state.loading = true;
+    const page = $('rpPage');
+    if (page) page.classList.add('rp-stale');
+    const saveBtn = $('rpSave');
+    if (saveBtn) saveBtn.disabled = true;
+    if (page && !state.report) page.innerHTML = '<div class="rp-loading">正在生成报告…</div>';
+    try {
+      const seq = ++state.loadSeq;
+      const { res, body } = await api('/api/ops/report?' + buildParams(force).toString());
+      if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
+      if (body.empty) {
+        page.innerHTML = '<div class="rp-loading">所选范围没有数据,请先导入明细。</div>';
+        return;
+      }
+      state.report = body.report;
+      renderReport(state.report);
+      if (!force && body.report.ai && body.report.ai.source !== 'llm' && !state.aiUpgradeTried) {
+        state.aiUpgradeTried = true;
+        upgradeToAi(seq);
+      }
+    } catch (e) {
+      page.innerHTML = `<div class="rp-loading">报告生成失败:${esc(e.message || e)}</div>`;
+    } finally {
+      state.loading = false;
+      if (page) page.classList.remove('rp-stale');
+      if (state.pending) {
+        state.pending = false;
+        load(true);
+      }
+    }
+  }
+
+  async function initDates() {
+    const search = new URLSearchParams(location.search);
+    const qFrom = String(search.get('from') || '').replace(/\D/g, '');
+    const qTo = String(search.get('to') || '').replace(/\D/g, '');
+    state.shops = String(search.get('shops') || '').split(',').map((s) => s.trim()).filter(Boolean);
+    state.schools = String(search.get('schools') || '').split(',').map((s) => s.trim()).filter(Boolean);
+
+    const { body } = await api('/api/ops/summary');
+    const summary = body || { schools: [], shops: [] };
+    const coverage = summary.coverage;
+    const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
+
+    if (/^\d{8}$/.test(qFrom) && /^\d{8}$/.test(qTo)) {
+      state.from = qFrom;
+      state.to = qTo;
+    } else if (coverage) {
+      const to = coverage.to;
+      const d = new Date(Date.UTC(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)) - 6));
+      const from = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
+      state.to = to;
+      state.from = from < coverage.from ? coverage.from : from;
+    }
+    if (state.from && state.to) {
+      $('rpFrom').value = toInput(state.from);
+      $('rpTo').value = toInput(state.to);
+    }
+    mountFilters(summary);
+    syncUrl();
+  }
+
+  function mountFilters(summary) {
+    const schools = (summary.schools || []).map((s) => ({ key: s.name, label: s.name, hint: `${s.shopCount || 0} 家` }));
+    window.createMultiSelect($('rpSchool'), {
+      label: '学校',
+      items: schools,
+      selected: state.schools,
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => {
+        state.schools = keys;
+        syncUrl();
+        load(true);
+      },
+    });
+    const shops = (summary.shops || []).map((s) => ({
+      key: String(s.key || s.shopId || ''),
+      label: s.shopName || s.key,
+      hint: s.school || '',
+    })).filter((item) => item.key);
+    window.createMultiSelect($('rpShop'), {
+      label: '店铺',
+      items: shops,
+      selected: state.shops,
+      allowEmpty: true,
+      emptyLabel: '全部',
+      onChange: (keys) => {
+        state.shops = keys;
+        syncUrl();
+        load(true);
+      },
+    });
+    window.bindMultiSelectDismiss();
+  }
+
+  function syncUrl() {
+    const params = new URLSearchParams({ view: 'report' });
+    if (state.from) params.set('from', state.from);
+    if (state.to) params.set('to', state.to);
+    if (state.schools.length) params.set('schools', state.schools.join(','));
+    if (state.shops.length) params.set('shops', state.shops.join(','));
+    history.replaceState(null, '', '/?' + params.toString());
+  }
+
+  window.shanReportInit = function () {
+    if (!state.inited) {
+      state.inited = true;
+      renderShell();
+      initDates().then(() => load());
+      window.addEventListener('resize', () => {
+        Object.values(state.charts).forEach((chart) => chart && chart.resize());
+      });
+      window.addEventListener('beforeprint', () => {
+        Object.values(state.charts).forEach((chart) => chart && chart.resize());
+      });
+    }
+  };
+})();

+ 342 - 0
public/schools.js

@@ -0,0 +1,342 @@
+'use strict';
+
+// 校区地图:中国地图 + 学校点位(启用=涟漪点亮,停用/未定位=灰点)+ 右侧列表。
+// 数据:GET /api/schools(JSON 种子 + 运行时合并);底图:/vendor/china-map.json
+(function () {
+  const $ = (id) => document.getElementById(id);
+  const CENTER = [104.5, 36];
+  const ZOOM = 1.55;
+
+  let chart = null;
+  let schools = [];
+  let cityPoints = [];
+  let geoProvinceNames = [];
+  let cityLabelsVisible = false;
+  let inited = false;
+  let loading = false;
+  let selectedId = null;
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function fmtDate(ymd) {
+    const s = String(ymd || '');
+    return /^\d{8}$/.test(s) ? `${Number(s.slice(4, 6))}/${Number(s.slice(6, 8))}` : '';
+  }
+
+  function opsText(school) {
+    if (!school.ops) return '运营数据不可用';
+    if (!school.ops.shopCount) return '未关联运营数据';
+    return `${school.ops.shopCount} 店${school.ops.lastDate ? ' · 最近 ' + fmtDate(school.ops.lastDate) : ''}`;
+  }
+
+  function shortProvince(name) {
+    const raw = String(name || '');
+    const short = raw
+      .replace(/特别行政区$/, '')
+      .replace(/(壮族|回族|维吾尔)?自治区$/, '')
+      .replace(/省$/, '')
+      .replace(/市$/, '');
+    return short || raw;
+  }
+
+  /** 门店数越多颜色越深,用来区分学校的经营体量 */
+  function shopColor(count) {
+    const n = Number(count) || 0;
+    if (n >= 10) return '#e8501f';
+    if (n >= 4) return '#ff8a5c';
+    if (n >= 1) return '#ffb894';
+    return '#d8d1c9';
+  }
+
+  /** 每省已点亮(启用且已定位)的学校数 */
+  function provinceCounts() {
+    const byShort = new Map();
+    for (const full of geoProvinceNames) byShort.set(shortProvince(full), full);
+    const counts = new Map();
+    for (const school of schools) {
+      if (!(school.enabled && school.lng != null)) continue;
+      const full = byShort.get(shortProvince(school.province)) || school.province;
+      if (!full) continue;
+      counts.set(full, (counts.get(full) || 0) + 1);
+    }
+    return counts;
+  }
+
+  /** 省份按点亮学校数分档着色(比学校点更浅,避免压过点位) */
+  function provinceColor(count) {
+    const n = Number(count) || 0;
+    if (n >= 10) return '#ef8455';
+    if (n >= 4) return '#f4a684';
+    if (n >= 2) return '#f8c9b2';
+    if (n >= 1) return '#f9dccc';
+    return '#f7f5f2';
+  }
+
+  function buildCityPoints(data) {
+    const points = [];
+    for (const info of Object.values(data || {})) {
+      const cities = (info && info.cities) || {};
+      for (const [name, coord] of Object.entries(cities)) {
+        if (Array.isArray(coord) && coord.length === 2) points.push({ name, value: coord });
+      }
+    }
+    return points;
+  }
+
+  function toPoint(school) {
+    return {
+      name: school.name,
+      value: [school.lng, school.lat],
+      schoolId: school.id,
+      itemStyle: { color: shopColor(school.ops && school.ops.shopCount) },
+    };
+  }
+
+  function buildOption() {
+    const on = schools.filter((s) => s.enabled && s.lng != null && s.lat != null);
+    const off = schools.filter((s) => !(s.enabled && s.lng != null && s.lat != null));
+    return {
+      tooltip: {
+        trigger: 'item',
+        backgroundColor: 'rgba(255,255,255,.96)',
+        borderColor: '#e8e2db',
+        borderWidth: 1,
+        padding: [8, 10],
+        textStyle: { color: '#3f3a35', fontSize: 12 },
+        formatter(params) {
+          const school = params.data && schools.find((s) => s.id === params.data.schoolId);
+          if (!school) {
+            const count = provinceCounts().get(params.name) || 0;
+            return `<b>${esc(params.name)}</b><br>点亮学校:${count} 所`;
+          }
+          const status = school.enabled ? '已启用' : '已停用';
+          const point = school.lng == null ? '未定位' : `${school.lng.toFixed(4)}, ${school.lat.toFixed(4)}`;
+          const origin = school.discovered ? '导入数据自动发现' : (school.seed ? '内置学校' : '手动创建');
+          const precision = school.lng == null ? '' : (school.approx ? '(大致位置,按高校坐标估算)' : '(已定位)');
+          return `<b>${esc(school.name)}</b><br>${esc(school.province || '—')}${school.city ? ' · ' + esc(school.city) : ''}<br>${status} · ${opsText(school)}<br>${origin}${precision}<br><span style="color:#9a938c">${point}</span>`;
+        },
+      },
+      geo: {
+        map: 'china',
+        roam: true,
+        center: CENTER,
+        zoom: ZOOM,
+        scaleLimit: { min: 1, max: 12 },
+        itemStyle: { areaColor: '#f7f5f2', borderColor: '#ddd6cd', borderWidth: 0.8 },
+        regions: [...provinceCounts().entries()].map(([name, count]) => ({
+          name,
+          itemStyle: { areaColor: provinceColor(count) },
+        })),
+        label: { show: true, color: '#8f8478', fontSize: 10, formatter: (p) => shortProvince(p.name) },
+        emphasis: { itemStyle: { areaColor: '#efeae4' }, label: { show: true, color: '#5b534c', fontSize: 10 } },
+        select: { disabled: true },
+      },
+      series: [
+        {
+          id: 'cities',
+          type: 'scatter',
+          coordinateSystem: 'geo',
+          data: cityPoints,
+          symbolSize: 2,
+          itemStyle: { color: '#d5cdc4' },
+          label: {
+            show: cityLabelsVisible,
+            position: 'right',
+            distance: 2,
+            fontSize: 9,
+            color: '#a39a91',
+            formatter: (p) => p.name,
+          },
+          labelLayout: { hideOverlap: true },
+          silent: true,
+          zlevel: 0,
+        },
+        {
+          id: 'on',
+          type: 'effectScatter',
+          coordinateSystem: 'geo',
+          data: on.map(toPoint),
+          symbolSize: 11,
+          showEffectOn: 'render',
+          rippleEffect: { brushType: 'stroke', scale: 3.4, period: 3.6 },
+          itemStyle: { shadowBlur: 8, shadowColor: 'rgba(255,107,53,.35)' },
+          label: {
+            show: true,
+            position: 'right',
+            distance: 6,
+            formatter: (p) => p.name,
+            fontSize: 11,
+            color: '#5b534c',
+          },
+          labelLayout: { hideOverlap: true },
+          emphasis: { scale: 1.4 },
+          zlevel: 2,
+        },
+        {
+          id: 'off',
+          type: 'scatter',
+          coordinateSystem: 'geo',
+          data: off.map(toPoint),
+          symbolSize: 6,
+          itemStyle: { color: '#b8b2ad' },
+          label: { show: false },
+          zlevel: 1,
+        },
+      ],
+    };
+  }
+
+  function renderLegend() {
+    const box = $('schoolsLegend');
+    if (!box) return;
+    const hasOps = schools.some((s) => s.ops && s.ops.shopCount > 0);
+    if (!hasOps) {
+      box.innerHTML = '';
+      return;
+    }
+    const litProvinces = provinceCounts();
+    const maxLit = Math.max(0, ...litProvinces.values());
+    box.innerHTML = '<span class="schools-legend-title">每省点亮学校</span>'
+      + '<span><i style="background:#f9dccc"></i>1</span>'
+      + '<span><i style="background:#f8c9b2"></i>2-3</span>'
+      + '<span><i style="background:#f4a684"></i>4-9</span>'
+      + '<span><i style="background:#ef8455"></i>10+</span>'
+      + '<span class="schools-legend-sep"></span>'
+      + '<span class="schools-legend-title">门店数</span>'
+      + '<span><i style="background:#d8d1c9"></i>0</span>'
+      + '<span><i style="background:#ffb894"></i>1-3</span>'
+      + '<span><i style="background:#ff8a5c"></i>4-9</span>'
+      + '<span><i style="background:#e8501f"></i>10+</span>'
+      + '<span class="schools-legend-sep"></span>'
+      + '<span><i class="hollow"></i>停用 / 未定位</span>';
+  }
+
+  function renderMap(force) {
+    const box = $('schoolsMap');
+    if (!box) return;
+    if (!chart) {
+      chart = echarts.init(box);
+      chart.on('georoam', () => {
+        const option = chart.getOption();
+        const zoom = (option.geo && option.geo[0] && option.geo[0].zoom) || ZOOM;
+        const shouldShow = zoom >= 2;
+        if (shouldShow === cityLabelsVisible) return;
+        cityLabelsVisible = shouldShow;
+        chart.setOption({ series: [{ id: 'cities', label: { show: shouldShow } }] });
+      });
+    }
+    chart.setOption(buildOption(), !!force);
+    renderLegend();
+  }
+
+  function renderList(data) {
+    const list = $('schoolsList');
+    const count = $('schoolsCount');
+    if (!list) return;
+    const enabled = data.enabledCount || 0;
+    if (count) count.textContent = `${enabled} / ${schools.length} 已启用`;
+
+    if (!schools.length) {
+      list.innerHTML = '<div class="empty-hint ops-empty" style="min-height:220px"><p>还没有学校</p><small>内置学校缺失,请检查 server/schools.seed.json</small></div>';
+      return;
+    }
+
+    const groups = new Map();
+    for (const school of schools) {
+      const key = school.province || '未标注省份';
+      if (!groups.has(key)) groups.set(key, []);
+      groups.get(key).push(school);
+    }
+
+    list.innerHTML = [...groups.entries()].map(([province, items]) => {
+      const onCount = items.filter((s) => s.enabled && s.lng != null).length;
+      const rows = items.map((s) => `
+        <div class="schools-item${s.id === selectedId ? ' on' : ''}" data-id="${esc(s.id)}">
+          <div class="schools-item-name">
+            <span class="schools-dot${s.enabled && s.lng != null ? '' : ' off'}"${s.enabled && s.lng != null ? ` style="background:${shopColor(s.ops && s.ops.shopCount)}"` : ''}></span>
+            <span>${esc(s.name)}</span>
+            ${s.seed ? '<span class="badge badge-muted">内置</span>' : ''}
+            ${s.discovered ? '<span class="badge badge-muted">导入</span>' : ''}
+            ${s.lng == null ? '<span class="badge badge-warn">未定位</span>' : (s.approx ? '<span class="badge badge-warn">大致位置</span>' : '')}
+          </div>
+          <div class="schools-item-meta">
+            <span>${esc(s.city || province)}</span>
+            <span>${esc(opsText(s))}</span>
+          </div>
+        </div>`).join('');
+      return `<div class="schools-group">${esc(province)} <em>${onCount}/${items.length}</em></div>${rows}`;
+    }).join('');
+
+    list.querySelectorAll('[data-id]').forEach((el) => {
+      el.addEventListener('click', () => selectSchool(el.dataset.id, true));
+    });
+  }
+
+  function selectSchool(id, fly) {
+    const school = schools.find((s) => s.id === id);
+    if (!school) return;
+    selectedId = id;
+    renderList({ enabledCount: schools.filter((s) => s.enabled && s.lng != null).length });
+    if (chart && school.lng != null) {
+      if (fly) chart.setOption({ geo: { center: [school.lng, school.lat], zoom: 8 } });
+      chart.dispatchAction({ type: 'highlight', seriesId: 'on', name: school.name });
+    }
+  }
+
+  function bind() {
+    const reset = $('btnSchoolsReset');
+    if (reset) {
+      reset.addEventListener('click', () => {
+        if (chart) chart.setOption({ geo: { center: CENTER, zoom: ZOOM } });
+      });
+    }
+    window.addEventListener('resize', () => { if (chart) chart.resize(); });
+  }
+
+  async function load() {
+    if (loading) return;
+    loading = true;
+    try {
+      const [geoRes, listRes, cityRes] = await Promise.all([
+        fetch('/vendor/china-map.json'),
+        fetch('/api/schools'),
+        fetch('/vendor/cn-cities.json').catch(() => null),
+      ]);
+      if (!geoRes.ok) throw new Error('底图加载失败');
+      const geo = await geoRes.json();
+      const data = await listRes.json();
+      echarts.registerMap('china', geo);
+      geoProvinceNames = (geo.features || []).map((f) => (f.properties && f.properties.name) || '').filter(Boolean);
+      if (cityRes && cityRes.ok) {
+        try { cityPoints = buildCityPoints(await cityRes.json()); } catch { cityPoints = []; }
+      }
+      schools = data.schools || [];
+      renderList(data);
+      renderMap(true);
+    } catch (e) {
+      const box = $('schoolsMap');
+      if (box) box.innerHTML = `<div class="empty-hint ops-empty"><p>地图加载失败</p><small>${esc(e.message || e)}</small></div>`;
+    } finally {
+      loading = false;
+    }
+  }
+
+  // 运营数据变更后,学校的关联门店数/最近日期会变,需要重新拉取
+  window.addEventListener('ops:data-changed', () => {
+    if (inited) load();
+  });
+
+  window.shanSchoolsInit = function () {
+    if (!inited) {
+      inited = true;
+      bind();
+      load();
+    } else if (chart) {
+      chart.resize();
+    }
+  };
+})();

+ 304 - 0
public/style.css

@@ -1180,3 +1180,307 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .harvest-path { max-width: 380px; white-space: normal !important; word-break: break-all; font-size: 12px; color: var(--text-2); }
 #harvestSummary details summary { cursor: pointer; font-size: 12px; color: var(--text-3); }
 #harvestSummary details pre { max-width: 420px; max-height: 160px; overflow: auto; margin: 4px 0 0; padding: 8px; border-radius: 8px; background: var(--border-light); white-space: pre-wrap; word-break: break-all; font-size: 11.5px; }
+
+/* ── 店铺运营(上传解析) ───────────────────────────────── */
+.ops-bar { display: flex; flex-wrap: wrap; gap: 12px 22px; align-items: center; padding: 14px 16px; margin-bottom: 14px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); }
+.ops-bar-group { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.ops-bar-right { margin-left: auto; }
+.ops-bar-label { font-size: var(--font-size-small); color: var(--text-3); white-space: nowrap; }
+.ops-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.ops-chip { display: inline-flex; align-items: center; gap: 5px; padding: 5px 10px; font-size: 12.5px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: 999px; cursor: pointer; transition: all .15s var(--ease-out); }
+.ops-chip:hover { border-color: var(--border-strong, var(--border)); color: var(--text); }
+.ops-chip.on { color: #fff; background: var(--accent); border-color: var(--accent); }
+.ops-chip em { font-style: normal; opacity: .7; font-size: 11px; }
+.ops-seg { display: inline-flex; padding: 2px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; }
+.ops-seg button { padding: 5px 12px; font-size: 12.5px; color: var(--text-2); background: none; border: 0; border-radius: 7px; cursor: pointer; }
+.ops-seg button.on { background: #fff; color: var(--text); box-shadow: 0 1px 2px rgba(31,27,24,.08); }
+.ops-input { height: 30px; padding: 0 8px; font-size: 12.5px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
+.ops-tilde { color: var(--text-3); }
+.ops-block { margin-bottom: 26px; }
+.ops-block-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 8px; }
+.ops-block-head h2 { margin: 0; font-size: 15px; font-weight: 650; color: var(--text); }
+.ops-block-head span { font-size: 12px; color: var(--text-3); }
+.ops-table-wrap { overflow: auto; max-height: 68vh; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); }
+.ops-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
+.ops-table th, .ops-table td { padding: 7px 9px; border-bottom: 1px solid var(--border-light); white-space: nowrap; }
+.ops-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bg); color: var(--text-2); font-weight: 600; text-align: center; }
+.ops-table thead tr:nth-child(2) th { top: 30px; }
+.ops-school-col { border-left: 2px solid var(--border); }
+.ops-date-col { font-variant-numeric: tabular-nums; min-width: 56px; }
+.ops-shop-col, .ops-shop-cell { position: sticky; left: 0; z-index: 3; min-width: 180px; max-width: 220px; background: var(--card-bg); text-align: left; overflow: hidden; text-overflow: ellipsis; }
+.ops-table thead .ops-shop-col { z-index: 4; background: var(--bg); }
+.ops-table:not(.ops-table-wide) tbody tr:hover td { background: #faf9f7; }
+.ops-table:not(.ops-table-wide) tbody tr:hover .ops-shop-cell { background: #faf9f7; }
+.ops-num { text-align: right; font-variant-numeric: tabular-nums; }
+.ops-null { color: var(--text-3); }
+.ops-warn { margin-left: 3px; color: var(--warn); cursor: help; font-size: 10px; }
+.ops-subtotal-row td { background: #fbfaf8; font-weight: 600; }
+.ops-subtotal { color: var(--text-2); }
+.ops-status { margin-left: 6px; }
+.ops-cards { display: flex; flex-direction: column; gap: 22px; }
+.ops-card-group h3 { margin: 0 0 10px; font-size: 14px; font-weight: 650; color: var(--text); }
+.ops-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(238px, 1fr)); gap: 12px; }
+.ops-card { padding: 14px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); transition: box-shadow .18s var(--ease-out), transform .18s var(--ease-out); }
+.ops-card:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(31,27,24,.06); }
+.ops-card-head { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
+.ops-card-name { flex: 1; min-width: 0; font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ops-card-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 10px; margin-bottom: 8px; }
+.ops-card-metric span { display: block; font-size: 11px; color: var(--text-3); }
+.ops-card-metric b { font-size: 15px; font-weight: 650; color: var(--text); font-variant-numeric: tabular-nums; }
+.ops-spark { width: 100%; height: 34px; display: block; }
+.ops-spark polyline { fill: none; stroke: var(--accent); stroke-width: 1.6; stroke-linejoin: round; stroke-linecap: round; }
+.ops-drop { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 26px 16px; text-align: center; color: var(--text-2); background: var(--bg); border: 1.5px dashed var(--border); border-radius: var(--radius-lg); cursor: pointer; transition: border-color .15s, background .15s; }
+.ops-drop.on, .ops-drop:hover { border-color: var(--accent); background: #fff8f4; }
+.ops-drop small { color: var(--text-3); }
+.ops-file-name { margin-top: 8px; font-size: 12.5px; color: var(--text-2); }
+.ops-field { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; }
+.ops-field label { font-size: 12.5px; color: var(--text-2); font-weight: 600; }
+.ops-field small { color: var(--text-3); font-size: 11.5px; }
+.ops-upload-result { margin-top: 14px; font-size: 12.5px; line-height: 1.6; }
+.ops-ok { padding: 10px 12px; color: var(--text); background: #f4fbf6; border: 1px solid #cfe9d8; border-radius: 10px; }
+.ops-error { color: #c0392b; }
+.ops-muted { color: var(--text-3); }
+.ops-warn-list { margin: 8px 0 0; padding-left: 18px; color: var(--text-2); }
+.ops-upload-modal { width: min(560px, 92vw); }
+.ops-records-modal { width: min(900px, 94vw); }
+.ops-records-modal .modal-body { max-height: 70vh; overflow: auto; }
+.ops-empty { min-height: 320px; }
+.ops-school-row td { background: #f7f5f2; font-weight: 650; color: var(--text-2); border-top: 1px solid var(--border); }
+.ops-school-row em { font-style: normal; margin-left: 8px; font-weight: 450; color: var(--text-3); font-size: 11.5px; }
+
+.ops-select { width: 360px; max-width: 100%; height: 32px; }
+#opsSchoolCustom { width: 360px; max-width: 100%; }
+.ops-field .ops-input[type="number"] { width: 140px; }
+.ops-file-list { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow: auto; }
+.ops-file-item { display: flex; align-items: center; gap: 8px; padding: 7px 10px; font-size: 12.5px; background: var(--bg); border: 1px solid var(--border-light); border-radius: 8px; }
+.ops-file-name2 { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
+.ops-file-size { flex: 0 0 auto; color: var(--text-3); font-size: 11.5px; }
+.ops-file-item em { font-style: normal; flex: 0 0 auto; font-size: 11.5px; }
+.ops-done-text { color: #2e7d4f; }
+.ops-file-del { flex: 0 0 auto; width: 20px; height: 20px; line-height: 1; padding: 0; color: var(--text-3); background: none; border: 0; border-radius: 50%; cursor: pointer; }
+.ops-file-del:hover { color: var(--text); background: var(--border-light); }
+.ops-check { display: flex; align-items: center; gap: 7px; margin-top: 14px; font-size: 12.5px; color: var(--text-2); cursor: pointer; }
+.ops-check input { accent-color: var(--accent); }
+
+/* ── 校区地图 ─────────────────────────────────────────── */
+.schools-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 16px; align-items: start; }
+.schools-map-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 6px; }
+.schools-map { width: 100%; height: clamp(420px, 68vh, 640px); }
+.schools-side { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
+.schools-side-head { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 14px; border-bottom: 1px solid var(--border-light); font-size: 13px; color: var(--text); }
+.schools-side-head span { font-size: 11.5px; color: var(--text-3); }
+.schools-list { max-height: clamp(420px, 68vh, 640px); overflow: auto; }
+.schools-group { padding: 10px 14px 4px; font-size: 11.5px; color: var(--text-3); background: #fbfaf8; }
+.schools-group em { font-style: normal; margin-left: 6px; opacity: .8; }
+.schools-item { padding: 10px 14px; border-bottom: 1px solid var(--border-light); cursor: pointer; transition: background .15s; }
+.schools-item:hover { background: #faf9f7; }
+.schools-item.on { background: #fff6f1; box-shadow: inset 3px 0 0 var(--accent); }
+.schools-item-name { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: var(--text); }
+.schools-item-name .badge { font-weight: 500; }
+.schools-dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px rgba(255, 107, 53, .16); }
+.schools-dot.off { background: #b8b2ad; box-shadow: none; }
+.schools-item-meta { margin-top: 4px; padding-left: 15px; display: flex; gap: 10px; font-size: 11.5px; color: var(--text-3); }
+@media (max-width: 1100px) {
+  .schools-layout { grid-template-columns: minmax(0, 1fr); }
+  .schools-list { max-height: 340px; }
+}
+.ops-metric-col { text-align: right; min-width: 92px; }
+.ops-table-wide .ops-date-col { min-width: 52px; text-align: center; }
+.ops-table-wide .ops-date-cell { text-align: center; color: var(--text-2); font-variant-numeric: tabular-nums; }
+.ops-subtotal-label { color: var(--text-2); font-weight: 600; }
+.ops-table-wide tbody tr.ops-subtotal-row td { border-top: 1px solid var(--border); }
+
+/* 店铺分块:斑马底 + 块间距 + 整块悬停 */
+.ops-table-wide .ops-shop-cell { vertical-align: top; padding-top: 11px; background: var(--card-bg); }
+.ops-table-wide tbody tr.ops-block-alt td { background: #fbf9f6; }
+.ops-table-wide tbody tr.ops-block-alt .ops-shop-cell { background: #f7f4ef; }
+.ops-table-wide tbody tr.ops-block-start td { border-top: 6px solid var(--bg); }
+.ops-table-wide tbody tr.ops-block-start .ops-shop-cell { border-top: 6px solid var(--bg); }
+.ops-table-wide tbody tr.ops-hover td { background: #fff3ec; }
+.ops-table-wide tbody tr.ops-hover .ops-shop-cell { background: #ffe9dc; }
+.ops-table-wide tbody tr.ops-hover .ops-date-cell { color: var(--text); }
+
+/* 多选下拉(字段 / 店铺) */
+.ops-multi { position: relative; }
+.ops-multi-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; font-size: 12.5px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; max-width: 260px; }
+.ops-multi-btn:hover { background: #fff; }
+.ops-multi-btn em { font-style: normal; color: var(--accent); font-weight: 600; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ops-multi-caret { color: var(--text-3); font-size: 10px; }
+.ops-multi-menu { position: absolute; z-index: 30; top: 34px; left: 0; width: 280px; max-height: 380px; display: flex; flex-direction: column; background: #fff; border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 12px 32px rgba(31,27,24,.14); overflow: hidden; }
+.ops-multi-menu[hidden] { display: none; }
+.ops-multi-search { margin: 8px; padding: 6px 8px; font-size: 12.5px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 7px; outline: none; }
+.ops-multi-search:focus { border-color: var(--accent); background: #fff; }
+.ops-multi-list { overflow: auto; max-height: 270px; padding: 0 6px 6px; }
+.ops-multi-item { display: flex; align-items: center; gap: 8px; padding: 6px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--text); }
+.ops-multi-item:hover { background: #faf7f3; }
+.ops-multi-item input { accent-color: var(--accent); flex: 0 0 auto; }
+.ops-multi-item span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ops-multi-item em { font-style: normal; color: var(--text-3); font-size: 11px; flex: 0 0 auto; }
+.ops-multi-foot { display: flex; gap: 8px; padding: 8px; border-top: 1px solid var(--border-light); }
+.ops-multi-foot button { flex: 1; padding: 5px 0; font-size: 12px; color: var(--text-2); background: var(--bg); border: 1px solid var(--border); border-radius: 7px; cursor: pointer; }
+.ops-multi-foot button:hover { color: var(--text); background: #fff; }
+.ops-multi-empty { padding: 14px; text-align: center; color: var(--text-3); font-size: 12px; }
+.ops-table thead th.ops-col-num { text-align: right; }
+.ops-table thead th.ops-col-text { text-align: left; }
+.ops-text-cell { text-align: left; color: var(--text); max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
+
+/* 报表列宽:固定布局,店铺 240 / 日期 72 定宽,指标列均分剩余宽度 */
+.ops-table-wide { width: 100%; table-layout: fixed; }
+.ops-table-wide th.ops-shop-col,
+.ops-table-wide td.ops-shop-cell { width: 240px; border-right: 1px solid var(--border-light); }
+.ops-table-wide th.ops-date-col,
+.ops-table-wide td.ops-date-cell { width: 72px; }
+.ops-table-wide th.ops-metric-col,
+.ops-table-wide td.ops-num,
+.ops-table-wide td.ops-text-cell { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ops-table-wide th.ops-metric-col { padding-left: 18px; padding-right: 18px; }
+.ops-table-wide td.ops-num,
+.ops-table-wide td.ops-text-cell { padding-left: 18px; padding-right: 18px; }
+.ops-table-wide tbody td { border-bottom-color: #f0ece7; }
+.ops-shop-name { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
+
+/* ── 经营驾驶舱 ───────────────────────────────────────── */
+.ckpt-bar { margin-bottom: 14px; }
+.ckpt-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; margin-bottom: 14px; }
+.ckpt-kpi { padding: 14px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); }
+.ckpt-kpi-label { font-size: 12px; color: var(--text-3); }
+.ckpt-kpi-value { margin: 6px 0 4px; font-size: clamp(19px, 1.6vw, 25px); font-weight: 650; color: var(--text); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
+.ckpt-kpi-foot { display: flex; align-items: center; gap: 6px; font-size: 11.5px; }
+.ckpt-delta { font-weight: 600; }
+.ckpt-delta.up { color: #2e7d4f; }
+.ckpt-delta.down { color: #c0392b; }
+.ckpt-delta.muted { color: var(--text-3); }
+.ckpt-kpi-hint { color: var(--text-3); }
+.ckpt-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
+.ckpt-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px 16px 8px; min-width: 0; }
+.ckpt-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
+.ckpt-card-head h3 { margin: 0; font-size: 13.5px; font-weight: 650; color: var(--text); }
+.ckpt-card-head span { font-size: 11.5px; color: var(--text-3); }
+.ckpt-chart { width: 100%; height: 260px; }
+.ckpt-empty { padding: 48px 0; text-align: center; color: var(--text-3); }
+.ckpt-empty p { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--text-2); }
+.ckpt-empty small { font-size: 12px; }
+@media (max-width: 1360px) {
+  .ckpt-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+}
+@media (max-width: 1080px) {
+  .ckpt-grid { grid-template-columns: 1fr; }
+}
+
+/* ── 驾驶舱 · AI 解读 ─────────────────────────────────── */
+.ckpt-ai { margin-bottom: 14px; padding: 14px 16px; background: linear-gradient(135deg, #fff 0%, #fff8f4 100%); border: 1px solid rgba(255, 107, 53, 0.22); border-radius: var(--radius-lg); }
+.ckpt-ai-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.ckpt-ai-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.ckpt-ai-title h3 { margin: 0; font-size: 13.5px; font-weight: 650; color: var(--text); }
+.ckpt-ai-badge { padding: 1px 6px; font-size: 10px; font-weight: 700; letter-spacing: .04em; color: #fff; background: var(--accent); border-radius: 5px; }
+.ckpt-ai-meta { font-size: 11.5px; color: var(--text-3); }
+.ckpt-ai-actions { display: flex; gap: 8px; flex: 0 0 auto; }
+.ckpt-ai-body { margin-top: 10px; font-size: 13px; line-height: 1.75; color: var(--text-2); }
+.ckpt-ai-list { margin: 0; padding-left: 18px; }
+.ckpt-ai-list li { margin: 3px 0; }
+.ckpt-ai-loading { color: var(--text-3); }
+.ckpt-ai-error { color: #c0392b; }
+
+/* ── 经营报告 ─────────────────────────────────────────── */
+.rp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+.rp-actions { display: flex; gap: 8px; }
+.rp-page { max-width: 1020px; margin: 0 auto; padding: 44px 52px 40px; background: #fff; border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 18px 48px rgba(31,27,24,.06); color: var(--text); }
+.rp-loading { padding: 80px 0; text-align: center; color: var(--text-3); font-size: 13px; }
+.rp-cover { display: flex; justify-content: space-between; gap: 32px; padding-bottom: 24px; border-bottom: 2px solid var(--text); }
+.rp-kicker { font-size: 11px; letter-spacing: .18em; color: var(--accent); font-weight: 700; }
+.rp-cover h1 { margin: 8px 0 6px; font-size: 30px; line-height: 1.25; letter-spacing: -0.02em; }
+.rp-cover-sub { font-size: 13px; color: var(--text-2); }
+.rp-cover-meta { flex: 0 0 auto; width: 280px; display: flex; flex-direction: column; gap: 8px; font-size: 11.5px; }
+.rp-cover-meta div { display: flex; gap: 8px; }
+.rp-cover-meta span { color: var(--text-3); flex: 0 0 56px; }
+.rp-cover-meta b { font-weight: 600; color: var(--text-2); word-break: break-all; }
+.rp-section { padding: 26px 0; border-bottom: 1px solid var(--border-light); }
+.rp-section:last-of-type { border-bottom: 0; }
+.rp-section h2 { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: 17px; letter-spacing: -0.01em; }
+.rp-section h2 em { font-style: normal; font-size: 12px; font-weight: 700; color: var(--accent); letter-spacing: .06em; }
+.rp-h3 { margin: 22px 0 10px; font-size: 14px; }
+.rp-lead { margin: 0 0 12px; font-size: 13.5px; line-height: 1.8; color: var(--text-2); }
+.rp-note { margin: 10px 0 0; font-size: 11.5px; line-height: 1.7; color: var(--text-3); }
+.rp-findings { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 12px; }
+.rp-findings li { display: flex; gap: 10px; font-size: 13px; line-height: 1.7; }
+.rp-findings b { font-weight: 600; }
+.rp-findings p { margin: 2px 0 0; color: var(--text-2); font-size: 12.5px; }
+.rp-dot { flex: 0 0 auto; width: 8px; height: 8px; margin-top: 7px; border-radius: 50%; background: var(--text-3); }
+.rp-dot.ok { background: #2e7d4f; }
+.rp-dot.warn { background: var(--accent); }
+.rp-dot.bad { background: #c0392b; }
+.rp-evidence { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+.rp-evidence code { padding: 1px 6px; font-size: 10.5px; color: var(--text-2); background: var(--bg); border-radius: 5px; }
+.rp-kpis { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 12px; }
+.rp-kpi { padding: 14px 16px; background: linear-gradient(135deg,#fff 0%,#faf8f5 100%); border: 1px solid var(--border-light); border-radius: 12px; }
+.rp-kpi-label { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--text-3); }
+.rp-kpi-value { margin: 8px 0 6px; font-size: 22px; font-weight: 650; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
+.rp-kpi-foot { display: flex; align-items: center; gap: 6px; font-size: 11.5px; }
+.rp-kpi-hint { color: var(--text-3); }
+.rp-delta.up { color: #2e7d4f; font-weight: 600; }
+.rp-delta.down { color: #c0392b; font-weight: 600; }
+.rp-delta.muted { color: var(--text-3); }
+.rp-figure { margin: 16px 0 6px; }
+.rp-figure figcaption { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
+.rp-figure figcaption b { font-size: 12.5px; }
+.rp-figure figcaption span { font-size: 11.5px; color: var(--text-3); }
+.rp-chart { width: 100%; height: 240px; }
+.rp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-top: 8px; }
+.rp-table th { padding: 8px 10px; text-align: left; font-weight: 600; color: var(--text-2); background: var(--bg); border-bottom: 1px solid var(--border); }
+.rp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border-light); }
+.rp-table tr:last-child td { border-bottom: 0; }
+.rp-alerts { display: flex; flex-direction: column; gap: 10px; }
+.rp-alert { padding: 12px 14px; background: #fbfaf8; border: 1px solid var(--border-light); border-left: 3px solid var(--text-3); border-radius: 10px; }
+.rp-alert.P0 { border-left-color: #c0392b; background: #fdf6f5; }
+.rp-alert.P1 { border-left-color: var(--accent); background: #fff8f4; }
+.rp-alert-head { display: flex; align-items: center; gap: 8px; font-size: 13px; }
+.rp-alert ul { margin: 8px 0 0; padding-left: 18px; font-size: 12px; color: var(--text-2); line-height: 1.7; }
+.rp-badge { padding: 1px 6px; font-size: 10px; font-weight: 700; color: #fff; background: var(--text-3); border-radius: 4px; }
+.rp-alert.P0 .rp-badge { background: #c0392b; }
+.rp-alert.P1 .rp-badge { background: var(--accent); }
+.rp-quality p { margin: 4px 0; font-size: 12.5px; color: var(--text-2); }
+.rp-method { margin: 6px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.8; color: var(--text-2); }
+.rp-footer { padding-top: 18px; border-top: 1px solid var(--border-light); font-size: 11px; color: var(--text-3); text-align: center; }
+
+@media print {
+  @page { size: A4; margin: 12mm 14mm; }
+  .sidebar, .rp-toolbar, .page-header, #splash { display: none !important; }
+  .app-frame { display: block; height: auto; overflow: visible; }
+  .app-frame > main { width: 100%; }
+  .content { padding: 0 !important; }
+  .view { display: none !important; }
+  .view.active { display: block !important; }
+  .rp-page { max-width: none; margin: 0; padding: 0; border: 0; box-shadow: none; }
+  .rp-section, .rp-kpi, .rp-figure, .rp-alert, .rp-table tr { break-inside: avoid; }
+  .rp-section h2 { break-after: avoid; }
+  body { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
+}
+.rp-range { display: flex; align-items: center; gap: 6px; margin-right: auto; }
+@media print { .rp-range { display: none !important; } }
+
+/* 报告中心 */
+.rp-history-modal { width: min(860px, 94vw); }
+.rp-history-kpis { color: var(--text-3); font-size: 11.5px; }
+.rp-col-actions { text-align: right; white-space: nowrap; }
+.rp-col-actions .btn { margin-left: 4px; }
+.rp-filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; margin-right: 8px; font-size: 11.5px; color: var(--text-2); background: #fff5ef; border: 1px solid rgba(255,107,53,.25); border-radius: 999px; }
+.rp-filter-chip button { padding: 0; font-size: 11.5px; color: var(--accent); background: none; border: 0; cursor: pointer; text-decoration: underline; }
+@media print { .rp-filter-chip { display: none !important; } }
+.rp-muted { color: var(--text-3); }
+.rp-toolbar { flex-wrap: wrap; }
+.rp-toolbar .ops-multi { min-width: 120px; }
+.rp-page.rp-stale { opacity: .55; transition: opacity .15s ease; }
+
+/* 校区地图:省名标签 + 门店数图例 */
+.schools-map-card { position: relative; }
+.schools-legend { position: absolute; left: 14px; bottom: 12px; display: flex; align-items: center; gap: 10px; padding: 6px 10px; font-size: 11px; color: var(--text-2); background: rgba(255,255,255,.92); border: 1px solid var(--border-light); border-radius: 8px; backdrop-filter: blur(4px); }
+.schools-legend i { display: inline-block; width: 8px; height: 8px; margin-right: 4px; border-radius: 50%; vertical-align: 0; }
+.schools-legend i.hollow { background: #fff; border: 1.5px solid #b8b2ad; }
+.schools-legend-title { color: var(--text-3); }
+.schools-legend-sep { width: 1px; height: 12px; background: var(--border); }
+
+/* 店铺运营:表头排序 */
+.ops-sortable { cursor: pointer; user-select: none; }
+.ops-sortable:hover { color: var(--accent); }
+.ops-sorted { color: var(--accent); }
+.ops-sort-arrow { margin-left: 4px; font-size: 10px; }

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/vendor/china-map.json


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/vendor/cn-cities.json


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/vendor/cn-universities.json


+ 98 - 0
schools-store.js

@@ -0,0 +1,98 @@
+'use strict';
+
+/**
+ * 校区地图 · 学校数据(JSON 静态存储)
+ * ----------------------------------------------------------------------------
+ * 两份文件合并成一份视图:
+ *   server/schools.seed.json  随版本发布的内置学校(只读,可被运行时覆盖 enabled/坐标)
+ *   data/schools.json         运行时新增/修改(gitignore;由 SCHOOLS_FILE 可覆盖,测试用)
+ *
+ * 合并规则:以 name 为唯一键,运行时同名字段覆盖种子;种子的 seed 标记始终保留。
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const ROOT_DIR = __dirname;
+const SEED_FILE = process.env.SCHOOLS_SEED_FILE
+  ? path.resolve(process.env.SCHOOLS_SEED_FILE)
+  : path.join(ROOT_DIR, 'server', 'schools.seed.json');
+const RUNTIME_FILE = process.env.SCHOOLS_FILE
+  ? path.resolve(process.env.SCHOOLS_FILE)
+  : path.join(ROOT_DIR, 'data', 'schools.json');
+
+function readJson(file, fallback) {
+  try {
+    return JSON.parse(fs.readFileSync(file, 'utf8'));
+  } catch (e) {
+    if (e.code !== 'ENOENT') console.warn(`[schools] 读取失败 ${file}: ${e.message}`);
+    return fallback;
+  }
+}
+
+function normalizeEntry(raw) {
+  if (!raw || !raw.name) return null;
+  const name = String(raw.name).trim();
+  if (!name) return null;
+  const lng = Number(raw.lng);
+  const lat = Number(raw.lat);
+  const hasPoint = Number.isFinite(lng) && Number.isFinite(lat)
+    && lng > 73 && lng < 136 && lat > 3 && lat < 54;
+  return {
+    id: raw.id || `sch_${Buffer.from(name).toString('base64url').slice(0, 10)}`,
+    name,
+    aliases: Array.isArray(raw.aliases) ? raw.aliases.map(String) : [],
+    province: raw.province || '',
+    city: raw.city || '',
+    county: raw.county || '',
+    lng: hasPoint ? lng : null,
+    lat: hasPoint ? lat : null,
+    source: raw.source || 'local',
+    seed: raw.seed === true,
+    enabled: raw.enabled !== false,
+    created_at: raw.created_at || '',
+    updated_at: raw.updated_at || '',
+  };
+}
+
+/** 读原始条目(不补默认值):合并时才能区分「显式给了空值」和「没给」 */
+function readRawList(file) {
+  const data = readJson(file, null);
+  if (!data || !Array.isArray(data.schools)) return [];
+  return data.schools.filter((item) => item && typeof item === 'object' && String(item.name || '').trim());
+}
+
+/** 合并后的学校列表(种子 + 运行时),按省份、名称排序 */
+function loadSchools() {
+  const merged = new Map();
+  for (const raw of readRawList(SEED_FILE)) {
+    const name = String(raw.name).trim();
+    merged.set(name, { ...raw, name, seed: true });
+  }
+  for (const raw of readRawList(RUNTIME_FILE)) {
+    const name = String(raw.name).trim();
+    const base = merged.get(name);
+    merged.set(name, {
+      // 只覆盖运行时显式给出的字段
+      ...(base || {}),
+      ...raw,
+      name,
+      // 种子标记不可被运行时文件伪造/清除
+      seed: !!(base && base.seed),
+    });
+  }
+  return [...merged.values()].map(normalizeEntry).filter(Boolean).sort((a, b) => {
+    const p = String(a.province).localeCompare(String(b.province), 'zh-Hans-CN');
+    return p !== 0 ? p : String(a.name).localeCompare(String(b.name), 'zh-Hans-CN');
+  });
+}
+
+function runtimeFile() {
+  return RUNTIME_FILE;
+}
+
+function seedFile() {
+  return SEED_FILE;
+}
+
+module.exports = { loadSchools, normalizeEntry, runtimeFile, seedFile };

+ 76 - 0
scripts/db-export.js

@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+'use strict';
+
+// 导出店铺运营数据为 JSON(用于 SQLite ↔ PostgreSQL 搬迁)
+// 用法:
+//   node scripts/db-export.js                      # 导出到 ops-export.json(不含原件)
+//   node scripts/db-export.js out.json --with-files # 连原件一起导出(base64)
+//
+// 说明:不带 --with-files 时只导出元数据与数据行;导入端要求每个上传都有原件,
+// 因此完整搬迁务必加 --with-files。
+
+const fs = require('fs');
+const path = require('path');
+const { createContext } = require('../server/context.js');
+const { createOpsStore } = require('../ops-store.js');
+
+(async () => {
+  const args = process.argv.slice(2);
+  const withFiles = args.includes('--with-files');
+  const outPath = path.resolve(args.find((a) => !a.startsWith('--')) || 'ops-export.json');
+
+  const ctx = createContext();
+  const store = createOpsStore(ctx.config);
+  const health = await store.health();
+  if (!health.ok) {
+    console.error(`读取失败:${health.reason || 'storage-unreachable'} ${health.detail || ''}`);
+    process.exit(1);
+  }
+
+  const uploads = await store.listUploads();
+  if (withFiles) {
+    for (const upload of uploads) {
+      const record = await store.getContent(upload.id);
+      upload.contentBase64 = record ? record.content.toString('base64') : '';
+    }
+  }
+  const rows = await store.queryRows({});
+  const platformRows = await store.queryPlatformRows({});
+  const summary = await store.summary();
+  // 经营报告历史(完整快照),迁移后仍可回看/下载
+  const reports = [];
+  for (const item of await store.listReports()) {
+    const full = await store.getReport(item.id);
+    if (full) reports.push({ ...item, report: full });
+  }
+
+  const payload = {
+    version: 1,
+    exportedAt: new Date().toISOString(),
+    driver: store.kind,
+    withFiles,
+    uploads,
+    shops: summary.shops,
+    platformRows,
+    reports,
+    rows: rows.map((r) => ({
+      date: r.date,
+      shopKey: r.shopKey,
+      uploadId: r.uploadId,
+      shopId: r.shopId,
+      shopName: r.shopName,
+      school: r.school,
+      status: r.status,
+      order_cnt: r.order_cnt,
+      revenue: r.revenue,
+      exposure: r.exposure,
+      click_rate: r.click_rate,
+      raw: r.raw,
+      quality: r.quality,
+    })),
+  };
+
+  fs.writeFileSync(outPath, JSON.stringify(payload, null, 2));
+  console.log(`已导出 ${uploads.length} 次上传、${rows.length} 行人工数据、${platformRows.length} 行平台明细、${reports.length} 份报告 → ${outPath}${withFiles ? '(含原件)' : '(不含原件)'}`);
+  await store.close();
+})();

+ 115 - 0
scripts/db-import.js

@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+'use strict';
+
+// 把 db-export.js 导出的 JSON 导入当前配置的存储(用于 SQLite → PostgreSQL 搬迁)
+// 用法:node scripts/db-import.js ops-export.json
+//
+// 约束:目标库应为空(上传 ID 是主键,重复导入会冲突)。要重来请先清空目标库。
+
+const fs = require('fs');
+const path = require('path');
+const { createContext } = require('../server/context.js');
+const { createOpsStore } = require('../ops-store.js');
+
+(async () => {
+  const input = process.argv[2];
+  if (!input) {
+    console.error('用法:node scripts/db-import.js <ops-export.json>');
+    process.exit(1);
+  }
+  const file = path.resolve(input);
+  if (!fs.existsSync(file)) {
+    console.error('找不到文件:' + file);
+    process.exit(1);
+  }
+  const payload = JSON.parse(fs.readFileSync(file, 'utf8'));
+  if (!payload || !Array.isArray(payload.uploads) || !Array.isArray(payload.rows)) {
+    console.error('文件格式不对:需要 uploads 与 rows 数组');
+    process.exit(1);
+  }
+  const missingContent = payload.uploads.filter((u) => !u.contentBase64);
+  if (missingContent.length) {
+    console.error(`有 ${missingContent.length} 次上传缺少原件(contentBase64)。请用 db-export.js --with-files 重新导出。`);
+    process.exit(1);
+  }
+
+  const ctx = createContext();
+  const store = createOpsStore(ctx.config);
+  const health = await store.health();
+  if (!health.ok) {
+    console.error(`目标存储不可用:${health.reason || 'storage-unreachable'} ${health.detail || ''}`);
+    process.exit(1);
+  }
+  console.log(`目标驱动:${store.kind}(${health.detail || ''})`);
+
+  try {
+    await store.withTransaction(async (tx) => {
+      for (const upload of payload.uploads) {
+        await tx.insertUpload({
+          id: upload.id,
+          originalName: upload.originalName,
+          sha256: upload.sha256,
+          sizeBytes: upload.sizeBytes,
+          uploadedAt: upload.uploadedAt,
+          year: upload.year,
+          parserKind: upload.parserKind,
+          parserVersion: upload.parserVersion,
+          report: upload.report || {},
+          content: Buffer.from(upload.contentBase64, 'base64'),
+        });
+      }
+      if (Array.isArray(payload.platformRows) && payload.platformRows.length) {
+        await tx.upsertPlatformRows(payload.platformRows.map((row) => ({
+          date: row.date,
+          shopId: row.shopId,
+          uploadId: row.uploadId,
+          school: row.school,
+          shopName: row.shopName,
+          data: row.data || {},
+        })));
+      }
+      await tx.upsertRows(payload.rows.map((row) => ({
+        date: row.date,
+        shopKey: row.shopKey,
+        uploadId: row.uploadId,
+        shopId: row.shopId,
+        shopName: row.shopName,
+        school: row.school,
+        status: row.status,
+        order_cnt: row.order_cnt,
+        revenue: row.revenue,
+        exposure: row.exposure,
+        click_rate: row.click_rate,
+        raw: row.raw || {},
+        quality: row.quality || [],
+      })));
+      const latestUploadId = payload.uploads.length ? payload.uploads[0].id : null;
+      await tx.upsertShops((payload.shops || []).map((shop) => ({ ...shop, lastUploadId: latestUploadId })));
+    });
+  } catch (e) {
+    console.error('导入失败:' + (e.message || e));
+    await store.close();
+    process.exit(1);
+  }
+
+  if (Array.isArray(payload.reports) && payload.reports.length) {
+    for (const item of payload.reports) {
+      await store.insertReport({
+        id: item.id,
+        createdAt: item.createdAt,
+        periodFrom: item.from,
+        periodTo: item.to,
+        schools: item.schools || [],
+        shops: item.shops || [],
+        source: item.source || 'rules',
+        model: item.model || '',
+        title: item.title || '经营分析报告',
+        report: item.report,
+      }).catch((err) => console.error('报告导入失败:' + item.id + ' ' + (err.message || err)));
+    }
+  }
+
+  const summary = await store.summary();
+  console.log(`导入完成:上传 ${summary.uploadCount} 次 · ${summary.rowCount} 行 · ${summary.schools.length} 个学校`);
+  await store.close();
+})();

+ 24 - 0
scripts/db-init.js

@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+'use strict';
+
+// 初始化存储(建表)并打印健康状况
+// 用法:npm run db:init
+
+const { createContext } = require('../server/context.js');
+const { createOpsStore } = require('../ops-store.js');
+
+(async () => {
+  const ctx = createContext();
+  const store = createOpsStore(ctx.config);
+  console.log(`存储驱动:${store.kind}`);
+  const health = await store.health();
+  if (!health.ok) {
+    console.error(`初始化失败:${health.reason || 'storage-unreachable'} ${health.detail || ''}`);
+    process.exit(1);
+  }
+  const summary = await store.summary();
+  console.log(`表已就绪(${health.detail || ''})`);
+  console.log(`当前数据:上传 ${summary.uploadCount} 次 · ${summary.rowCount} 行 · ${summary.schools.length} 个学校`);
+  if (summary.coverage) console.log(`覆盖区间:${summary.coverage.from} ~ ${summary.coverage.to}`);
+  await store.close();
+})();

BIN
scripts/fixtures/ops-manual-sample.xlsx


BIN
scripts/fixtures/platform-export-sample.xlsx


BIN
scripts/fixtures/platform-export-variant.xlsx


+ 54 - 2
scripts/smoke.js

@@ -118,10 +118,18 @@ async function main() {
   const tmpMain = fs.mkdtempSync(path.join(os.tmpdir(), 'genpic-smoke-main-'));
   const tmpMainLake = path.join(tmpMain, 'lake');
   fs.mkdirSync(tmpMainLake, { recursive: true });
+  // 输出根目录也用临时目录:避免依赖本机 workspace 是否存在(scan/creations 需要可读目录)
+  const tmpMainWorkspace = path.join(tmpMain, 'workspace');
+  fs.mkdirSync(tmpMainWorkspace, { recursive: true });
   const tmpMainConfig = path.join(tmpMain, 'config.json');
-  fs.writeFileSync(tmpMainConfig, JSON.stringify({ aitable: { enabled: false } }));
+  fs.writeFileSync(tmpMainConfig, JSON.stringify({
+    aitable: { enabled: false },
+    // ops 库也指到临时目录:冒烟不触碰本机 data/ops.db
+    storage: { driver: 'sqlite', sqlite: { file: path.join(tmpMain, 'ops.db') } },
+    rootDir: tmpMainWorkspace,
+  }));
   const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
-    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig, NAPOS_CDP_PORT: '1' },
+    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig, NAPOS_CDP_PORT: '1', SCHOOLS_FILE: path.join(tmpMain, 'schools.json') },
     stdio: ['ignore', 'pipe', 'pipe'],
   });
   let bootLog = '';
@@ -334,6 +342,50 @@ async function main() {
         `status=${r.status} conclusion=${j.conclusion}`);
     });
 
+    // ---- 店铺运营(上传解析)只读断言:不触发上传/重解析等写操作 ----
+    await check('GET /api/ops/summary(空库 → enabled:true)', async () => {
+      const r = await request(port, 'GET', '/api/ops/summary');
+      const d = r.json();
+      expect(r.status === 200 && d.enabled === true && d.rowCount === 0, `status=${r.status} enabled=${d.enabled}`);
+    });
+    await check('GET /api/ops/uploads(空库 → 空数组)', async () => {
+      const r = await request(port, 'GET', '/api/ops/uploads');
+      expect(r.status === 200 && Array.isArray(r.json().uploads) && r.json().uploads.length === 0, `status=${r.status}`);
+    });
+    await check('GET /api/schools(内置学校可见)', async () => {
+      const r = await request(port, 'GET', '/api/schools');
+      const d = r.json();
+      expect(r.status === 200 && Array.isArray(d.schools), `status=${r.status}`);
+      expect(d.schools.some((s) => s.name === '浙江工商大学桐庐校区'), '缺少内置学校');
+      expect(d.schools.every((s) => 'ops' in s), '缺少关联字段');
+    });
+    await check('GET /api/ops/fields(默认 4 字段)', async () => {
+      const r = await request(port, 'GET', '/api/ops/fields');
+      const d = r.json();
+      expect(r.status === 200 && Array.isArray(d.fields) && d.fields.length >= 4, `status=${r.status}`);
+      expect(Array.isArray(d.defaults) && d.defaults.length === 4, 'defaults 不对');
+    });
+    await check('GET /api/ops/dashboard(空库 → 空 KPI)', async () => {
+      const r = await request(port, 'GET', '/api/ops/dashboard');
+      const d = r.json();
+      expect(r.status === 200 && d.kpis === null && Array.isArray(d.trend), `status=${r.status}`);
+    });
+    await check('GET /api/ops/report(空库 → empty)', async () => {
+      const r = await request(port, 'GET', '/api/ops/report');
+      const d = r.json();
+      expect(r.status === 200 && d.empty === true, `status=${r.status}`);
+    });
+    await check('GET /api/ops/reports(空库 → 空列表)', async () => {
+      const r = await request(port, 'GET', '/api/ops/reports');
+      const d = r.json();
+      expect(r.status === 200 && Array.isArray(d.reports), `status=${r.status}`);
+    });
+    await check('GET /api/ops/rows(空库 → 空矩阵)', async () => {
+      const r = await request(port, 'GET', '/api/ops/rows');
+      const d = r.json();
+      expect(r.status === 200 && Array.isArray(d.groups) && d.groups.length === 0 && Array.isArray(d.dates), `status=${r.status}`);
+    });
+
     // ---- 营业状态快照(busyLevel)与定向采集路由 ----
     await check('busyLevel → 快照落库 + 报表行回填 + 不产生多余行(隔离子进程)', async () => {
       const blake = path.join(tmpMain, 'lake-busy');

+ 563 - 0
scripts/test-ops-import.js

@@ -0,0 +1,563 @@
+#!/usr/bin/env node
+'use strict';
+
+// 店铺运营数据:解析器单测 + 端到端接口测试
+// 全部本地跑:临时 SQLite 文件 + 随机端口,不依赖网络与云端数据库。
+// 用法:npm run test:ops
+
+const { spawn } = require('child_process');
+const net = require('net');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const crypto = require('crypto');
+
+const ROOT = path.resolve(__dirname, '..');
+const FIXTURE = path.join(__dirname, 'fixtures', 'ops-manual-sample.xlsx');
+const PLATFORM_FIXTURE = path.join(__dirname, 'fixtures', 'platform-export-sample.xlsx');
+const PLATFORM_VARIANT = path.join(__dirname, 'fixtures', 'platform-export-variant.xlsx');
+const importer = require(path.join(ROOT, 'ops-import.js'));
+
+const results = [];
+function expect(cond, msg) {
+  if (!cond) throw new Error(msg);
+}
+async function check(name, fn) {
+  try {
+    await fn();
+    results.push({ name, ok: true });
+  } catch (e) {
+    results.push({ name, ok: false, error: e.message || String(e) });
+  }
+}
+
+function pickPort() {
+  return new Promise((resolve, reject) => {
+    const srv = net.createServer();
+    srv.unref();
+    srv.on('error', reject);
+    srv.listen(0, '127.0.0.1', () => {
+      const port = srv.address().port;
+      srv.close(() => resolve(port));
+    });
+  });
+}
+
+async function waitForHealth(port, timeoutMs = 20000) {
+  const start = Date.now();
+  for (;;) {
+    try {
+      const res = await fetch(`http://127.0.0.1:${port}/healthz`);
+      if (res.ok) return;
+    } catch { /* 还没起来 */ }
+    if (Date.now() - start > timeoutMs) throw new Error('服务未在超时内启动');
+    await new Promise((r) => setTimeout(r, 300));
+  }
+}
+
+async function readJson(res) {
+  const text = await res.text();
+  try {
+    return { status: res.status, body: JSON.parse(text) };
+  } catch {
+    return { status: res.status, body: { raw: text } };
+  }
+}
+
+async function main() {
+  const fixture = fs.readFileSync(FIXTURE);
+  const platformFixture = fs.readFileSync(PLATFORM_FIXTURE);
+  const platformVariant = fs.readFileSync(PLATFORM_VARIANT);
+  const fixtureSha = crypto.createHash('sha256').update(fixture).digest('hex');
+
+  // ---------- 解析器单测 ----------
+
+  await check('cleanValue: 空值 → null + missing', () => {
+    const r = importer.cleanValue(null, 'order_cnt');
+    expect(r.value === null, 'value 应为 null');
+    expect(r.reasons.includes('missing'), '应标记 missing');
+  });
+
+  await check('cleanValue: 0 与 null 可区分', () => {
+    const zero = importer.cleanValue(0, 'order_cnt');
+    expect(zero.value === 0, '0 应保留为 0');
+    expect(zero.reasons.length === 0, '0 不应有质量标记');
+    const empty = importer.cleanValue('', 'order_cnt');
+    expect(empty.value === null, '空串应为 null');
+  });
+
+  await check('cleanValue: 10..6% → 0.106 + malformed-percent', () => {
+    const r = importer.cleanValue('10..6%', 'click_rate');
+    expect(Math.abs(r.value - 0.106) < 1e-9, `实际 ${r.value}`);
+    expect(r.reasons.includes('malformed-percent'), '应标记 malformed-percent');
+  });
+
+  await check('cleanValue: 12.3 → 0.123 + percent-scaled', () => {
+    const r = importer.cleanValue(12.3, 'click_rate');
+    expect(Math.abs(r.value - 0.123) < 1e-9, `实际 ${r.value}`);
+    expect(r.reasons.includes('percent-scaled'), '应标记 percent-scaled');
+  });
+
+  await check('cleanValue: 未开放 → null + non-numeric', () => {
+    const r = importer.cleanValue('未开放', 'order_cnt');
+    expect(r.value === null, 'value 应为 null');
+    expect(r.reasons.includes('non-numeric'), '应标记 non-numeric');
+    expect(r.rawText === '未开放', '应保留原文');
+  });
+
+  await check('parseDateColumns: 9.9 + year 2026 → 20260909', () => {
+    const cols = importer.parseDateColumns(['9.9订单量', '10.7营收'], 2026);
+    expect(cols.length === 2, `列数 ${cols.length}`);
+    expect(cols[0].date === '20260909', cols[0].date);
+    expect(cols[1].date === '20261007', cols[1].date);
+  });
+
+  await check('parseDateColumns: 跨年 12.31 → 1.1 年 +1', () => {
+    const cols = importer.parseDateColumns(['12.31订单量', '1.1订单量'], 2025);
+    expect(cols[0].date === '20251231', cols[0].date);
+    expect(cols[1].date === '20260101', cols[1].date);
+  });
+
+  await check('detect: fixture 识别为 manual-ops', () => {
+    expect(importer.detectBuffer(fixture) === 'manual-ops', '未识别');
+  });
+
+  const parsed = importer.parseWorkbook(fixture, { year: 2026 });
+  await check('fixture 解析行数 = 36 店 × 29 天', () => {
+    expect(parsed.rows.length === 1044, `实际 ${parsed.rows.length}`);
+    expect(parsed.shops.length === 36, `shops ${parsed.shops.length}`);
+    expect(parsed.dates.length === 29, `dates ${parsed.dates.length}`);
+    expect(parsed.dates[0] === '20260909' && parsed.dates[28] === '20261007', '日期范围');
+  });
+
+  await check('fixture 脏值已清洗', () => {
+    const dirty = parsed.rows.find((r) => (r.raw.click_rate || '').includes('10..6'));
+    expect(dirty, '找不到 10..6% 的行');
+    expect(Math.abs(dirty.click_rate - 0.106) < 1e-9, `实际 ${dirty.click_rate}`);
+    expect(dirty.quality.includes('malformed-percent'), '缺少 malformed-percent');
+    const scaled = parsed.rows.find((r) => Number(r.raw.click_rate) === 12.3);
+    expect(scaled && scaled.quality.includes('percent-scaled'), '12.3 未按百分数处理');
+    const nonNumeric = parsed.rows.find((r) => (r.raw.order_cnt || '') === '未开放');
+    expect(nonNumeric && nonNumeric.order_cnt === null, '未开放 未转为 null');
+  });
+
+  await check('fixture 无 ID 店铺用 school|shopName 兜底', () => {
+    const noId = parsed.rows.filter((r) => r.quality.includes('no-shop-id'));
+    expect(noId.length > 0, '应存在无 ID 店铺');
+    expect(noId.every((r) => r.shopKey.includes('|')), 'shopKey 兜底格式不对');
+  });
+
+  await check('fixture 指定学校时覆盖表格里的学校列', () => {
+    const forced = importer.parseWorkbook(fixture, { year: 2026, school: '统一校区' });
+    expect(forced.rows.length === 1044, `rows ${forced.rows.length}`);
+    expect(forced.rows.every((r) => r.school === '统一校区'), '仍有行没被覆盖');
+    expect([...new Set(forced.shops.map((x) => x.school))].length === 1, '学校数应为 1');
+    expect(forced.shops.every((x) => x.school === '统一校区'), '店铺注册表学校未覆盖');
+  });
+
+  await check('无「学校名称」列时 school 为空(由上传接口要求补选)', () => {
+    const cells = [
+      ['店铺名称', 'ID', '9.9订单量', '9.9营收', '9.9曝光量', '9.9进店转换率'],
+      ['测试店', '123', 1, 2, 3, 0.1],
+    ];
+    const out = importer.parseManualOps(cells, { year: 2026 });
+    expect(out.rows.length === 1, `rows ${out.rows.length}`);
+    expect(out.rows[0].school === '', `school='${out.rows[0].school}'`);
+  });
+
+  await check('fixture 占位行未产生数据', () => {
+    expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名');
+  });
+
+  // ---------- 平台导出(全字段明细) ----------
+
+  await check('platform: 识别为 platform-export', () => {
+    expect(importer.detectBuffer(platformFixture) === 'platform-export', '识别失败');
+  });
+
+  const platformParsed = importer.parseAny(platformFixture, { school: '测试校区' });
+  await check('platform: 10 行 / 10 店 / 124 字段', () => {
+    expect(platformParsed.kind === 'platform-export', platformParsed.kind);
+    expect(platformParsed.rows.length === 10, `rows ${platformParsed.rows.length}`);
+    expect(platformParsed.shops.length === 10, `shops ${platformParsed.shops.length}`);
+    expect(Object.keys(platformParsed.rows[0].data).length === 124, `fields ${Object.keys(platformParsed.rows[0].data).length}`);
+    expect(platformParsed.rows[0].date === '20261018', platformParsed.rows[0].date);
+    expect(platformParsed.rows[0].school === '测试校区', platformParsed.rows[0].school);
+  });
+
+  await check('platform: 日期归一化', () => {
+    const cells = [
+      ['日期', '门店编号', '门店名称', '有效订单'],
+      ['2026/1/9', '1', 'A店', 1],
+      ['2026.10.18', '2', 'B店', 2],
+    ];
+    const r = importer.parsePlatformExport(cells, {});
+    expect(r.rows[0].date === '20260109', r.rows[0].date);
+    expect(r.rows[1].date === '20261018', r.rows[1].date);
+  });
+
+  // ---------- 端到端(临时 SQLite) ----------
+
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-'));
+  const dbFile = path.join(tmp, 'ops.db');
+  const configPath = path.join(tmp, 'config.json');
+  fs.writeFileSync(configPath, JSON.stringify({
+    aitable: { enabled: false },
+    storage: { driver: 'sqlite', sqlite: { file: dbFile } },
+  }));
+  const port = await pickPort();
+  const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
+    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_CONFIG: configPath, GENPIC_LAKE_DIR: path.join(tmp, 'lake'), NAPOS_CDP_PORT: '1' },
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+  let bootLog = '';
+  child.stdout.on('data', (c) => { bootLog += c; });
+  child.stderr.on('data', (c) => { bootLog += c; });
+
+  const base = `http://127.0.0.1:${port}`;
+  const upload = (force) => fetch(base + '/api/ops/upload', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: !!force }),
+  }).then(readJson);
+
+  try {
+    await waitForHealth(port);
+
+    await check('E2E 空态 summary', async () => {
+      const r = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.enabled === true, 'enabled 应为 true');
+      expect(r.body.rowCount === 0, 'rowCount 应为 0');
+      expect(r.body.driver === 'sqlite', `driver ${r.body.driver}`);
+    });
+
+    let uploadId = '';
+    await check('E2E 上传成功', async () => {
+      const r = await upload(false);
+      expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
+      uploadId = r.body.uploadId;
+      expect(/^\d{8}-\d{6}-[a-z0-9]{6}$/.test(uploadId), `uploadId ${uploadId}`);
+      expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
+      expect(r.body.report.shops === 36, `shops ${r.body.report.shops}`);
+    });
+
+    await check('E2E 重复上传 409', async () => {
+      const r = await upload(false);
+      expect(r.status === 409, `status ${r.status}`);
+      expect(r.body.uploadId === uploadId, '应返回已存在的 uploadId');
+    });
+
+    await check('E2E force 覆盖导入', async () => {
+      const r = await upload(true);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.report.overwritten === 1044, `overwritten ${r.body.report.overwritten}`);
+    });
+
+    await check('E2E summary 有数据', async () => {
+      const r = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(r.body.rowCount === 1044, `rowCount ${r.body.rowCount}`);
+      expect(r.body.schools.length === 3, `schools ${r.body.schools.length}`);
+      expect(r.body.coverage.from === '20260909' && r.body.coverage.to === '20261007', 'coverage');
+    });
+
+    await check('E2E 矩阵查询 + 字段过滤', async () => {
+      const r = await fetch(base + '/api/ops/rows?fields=order_cnt').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.dates.length === 29, `dates ${r.body.dates.length}`);
+      expect(r.body.fields.length === 1 && r.body.fields[0].key === 'order_cnt', 'fields 过滤失效');
+      expect(r.body.groups.length === 3, `groups ${r.body.groups.length}`);
+      const shop = r.body.groups[0].shops[0];
+      expect(typeof shop.totals.order_cnt === 'number', 'totals 缺失');
+    });
+
+    await check('E2E 学校过滤', async () => {
+      const r = await fetch(base + '/api/ops/rows?schools=' + encodeURIComponent('样例学校A')).then(readJson);
+      expect(r.body.groups.length === 1, `groups ${r.body.groups.length}`);
+      expect(r.body.groups[0].school === '样例学校A', r.body.groups[0].school);
+    });
+
+    await check('E2E 下载原件字节一致', async () => {
+      const res = await fetch(`${base}/api/ops/uploads/${uploadId}/file`);
+      expect(res.status === 200, `status ${res.status}`);
+      const buf = Buffer.from(await res.arrayBuffer());
+      const sha = crypto.createHash('sha256').update(buf).digest('hex');
+      expect(sha === fixtureSha, 'sha256 不一致');
+    });
+
+    await check('E2E 非法 uploadId 400', async () => {
+      const res = await fetch(`${base}/api/ops/uploads/${encodeURIComponent('../etc')}/file`);
+      expect(res.status === 400, `status ${res.status}`);
+    });
+
+    await check('E2E 不存在的 uploadId 404', async () => {
+      const res = await fetch(`${base}/api/ops/uploads/20000101-000000-abcdef/file`);
+      expect(res.status === 404, `status ${res.status}`);
+    });
+
+    await check('E2E 重新解析', async () => {
+      const r = await fetch(`${base}/api/ops/uploads/${uploadId}/reparse`, { method: 'POST' }).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.report.rows === 1044, `rows ${r.body.report.rows}`);
+      const sum = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(sum.body.rowCount === 1044, `rowCount ${sum.body.rowCount}`);
+    });
+
+    await check('E2E 导出 CSV', async () => {
+      const res = await fetch(base + '/api/ops/export?fields=order_cnt');
+      expect(res.status === 200, `status ${res.status}`);
+      const text = await res.text();
+      expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type');
+      expect(text.includes('样例学校A'), 'CSV 缺少学校列');
+      expect(text.split('\n')[0].includes('日期') && text.split('\n')[0].includes('订单量'), 'CSV 缺少日期/订单量列');
+      expect(text.trim().split('\n').length === 1045, `行数 ${text.trim().split('\n').length}`);
+    });
+
+    await check('E2E 非 xlsx 拒绝', async () => {
+      const res = await fetch(base + '/api/ops/upload', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ fileName: 'a.txt', contentBase64: Buffer.from('x').toString('base64'), year: 2026 }),
+      });
+      expect(res.status === 400, `status ${res.status}`);
+    });
+
+    await check('E2E 坏文件 400 且不落库', async () => {
+      const before = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
+      const res = await fetch(base + '/api/ops/upload', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ fileName: 'bad.xlsx', contentBase64: Buffer.from('not a zip file at all').toString('base64'), year: 2026 }),
+      });
+      expect(res.status === 400, `status ${res.status}`);
+      const after = (await fetch(base + '/api/ops/uploads').then(readJson)).body.uploads.length;
+      expect(after === before, '坏文件不应产生上传记录');
+    });
+
+    await check('E2E 上传时指定学校 → 覆盖并写入', async () => {
+      const res = await fetch(base + '/api/ops/upload', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ fileName: '样例.xlsx', contentBase64: fixture.toString('base64'), year: 2026, force: true, school: '测试校区' }),
+      }).then(readJson);
+      expect(res.status === 200, `status ${res.status}`);
+      const sum = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
+    });
+
+    await check('E2E 重新解析保留指定学校', async () => {
+      const list = await fetch(base + '/api/ops/uploads').then(readJson);
+      const id = list.body.uploads[0].id;
+      const r = await fetch(`${base}/api/ops/uploads/${id}/reparse`, { method: 'POST' }).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      const sum = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(sum.body.schools.length === 1 && sum.body.schools[0].name === '测试校区', JSON.stringify(sum.body.schools));
+    });
+
+    const uploadPlatform = (buf, force, school) => fetch(base + '/api/ops/upload', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        fileName: '平台导出.xlsx',
+        contentBase64: buf.toString('base64'),
+        year: 2026,
+        force: !!force,
+        ...(school === undefined ? {} : { school }),
+      }),
+    }).then(readJson);
+
+    await check('E2E 平台导出:未选学校 → 400 提示补选', async () => {
+      const r = await uploadPlatform(platformFixture);
+      expect(r.status === 400 && r.body.code === 'OPS_SCHOOL_REQUIRED', `status ${r.status} ${JSON.stringify(r.body)}`);
+    });
+
+    await check('E2E 平台导出:首次导入 10 行', async () => {
+      const r = await uploadPlatform(platformFixture, false, '测试校区');
+      expect(r.status === 200, `status ${r.status} ${JSON.stringify(r.body).slice(0, 200)}`);
+      expect(r.body.report.kind === 'platform-export', r.body.report.kind);
+      expect(r.body.report.newRows === 10, `newRows ${r.body.report.newRows}`);
+      expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`);
+    });
+
+    await check('E2E 平台导出:同文件强制重传 → 全部已存在', async () => {
+      const r = await uploadPlatform(platformFixture, true, '测试校区');
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.report.newRows === 0, `newRows ${r.body.report.newRows}`);
+      expect(r.body.report.identicalRows === 10, `identical ${r.body.report.identicalRows}`);
+      expect(r.body.duplicateData === true, '应标记重复');
+    });
+
+    await check('E2E 平台导出:内容变化 → 冲突 1 行', async () => {
+      const r = await uploadPlatform(platformVariant, false, '测试校区');
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.report.conflictRows === 1, `conflict ${r.body.report.conflictRows}`);
+      expect(r.body.report.identicalRows === 9, `identical ${r.body.report.identicalRows}`);
+      expect(r.body.report.writtenRows === 0, `written ${r.body.report.writtenRows}`);
+    });
+
+    await check('E2E 平台导出:强制覆盖冲突', async () => {
+      const r = await uploadPlatform(platformVariant, true, '测试校区');
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.report.writtenRows === 10, `written ${r.body.report.writtenRows}`);
+      const sum = await fetch(base + '/api/ops/summary').then(readJson);
+      expect(sum.body.platformRowCount === 10, `platformRows ${sum.body.platformRowCount}`);
+    });
+
+    await check('E2E 字段目录包含平台列', async () => {
+      const r = await fetch(base + '/api/ops/fields').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.defaults.join(',') === 'order_cnt,revenue,exposure,click_rate', '默认字段不对');
+      expect(r.body.fields.length > 4, `fields ${r.body.fields.length}`);
+      expect(r.body.fields.some((f) => f.key === 'p:营业额' && f.type === 'money'), '缺少营业额字段');
+    });
+
+    await check('E2E 店铺筛选', async () => {
+      const all = await fetch(base + '/api/ops/rows').then(readJson);
+      const shop = all.body.groups[0].shops[0];
+      const r = await fetch(base + '/api/ops/rows?shops=' + encodeURIComponent(shop.shopId || shop.key)).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      const shops = r.body.groups.flatMap((g) => g.shops);
+      expect(shops.length === 1, `shops ${shops.length}`);
+    });
+
+    await check('E2E 驾驶舱:KPI 与图表数据', async () => {
+      const r = await fetch(base + '/api/ops/dashboard').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      const d = r.body;
+      expect(d.kpis && d.kpis.revenue.value > 0, `revenue ${d.kpis && d.kpis.revenue.value}`);
+      expect(d.trend.length >= 1, `trend ${d.trend.length}`);
+      expect(d.structure.length === d.trend.length, '结构图与趋势日期数不一致');
+      expect(d.ranking.length <= 10 && d.ranking.length > 0, `ranking ${d.ranking.length}`);
+      expect(d.funnel.exposure >= 0 && d.funnel.visit >= 0, '漏斗数据异常');
+      expect(Array.isArray(d.zeroOrder), '缺少零单诊断');
+      expect(d.zeroOrder.some((z) => z.reason === 'not-open'), '未识别出未开业门店');
+    });
+
+    await check('E2E 驾驶舱:店铺筛选后金额变化', async () => {
+      const all = await fetch(base + '/api/ops/dashboard').then(readJson);
+      const shopId = all.body.ranking[0].shopId;
+      const one = await fetch(base + '/api/ops/dashboard?shops=' + encodeURIComponent(shopId)).then(readJson);
+      expect(one.status === 200, `status ${one.status}`);
+      expect(one.body.kpis.revenue.value > 0, '单店营收为 0');
+      expect(one.body.kpis.revenue.value < all.body.kpis.revenue.value, '单店营收不应大于全部');
+      expect(one.body.ranking.length === 1, `ranking ${one.body.ranking.length}`);
+    });
+
+    await check('E2E 经营解读:无模型时退回规则版摘要', async () => {
+      const r = await fetch(base + '/api/ops/insights', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({}),
+      }).then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.enabled === true, JSON.stringify(r.body).slice(0, 120));
+      expect(r.body.source === 'rules', `source ${r.body.source}`);
+      expect(Array.isArray(r.body.points) && r.body.points.length > 0, 'points 为空');
+      expect(!!r.body.aiError, '应提示模型未配置');
+    });
+
+    await check('E2E 报告缓存:二次读取命中', async () => {
+      const first = await fetch(base + '/api/ops/report').then(readJson);
+      const second = await fetch(base + '/api/ops/report').then(readJson);
+      expect(first.status === 200 && second.status === 200, `status ${first.status}/${second.status}`);
+      expect(second.body.report && second.body.report.cached === true, '二次读取未命中缓存');
+    });
+
+    await check('E2E 报告中心:保存 / 列表 / 查看 / 删除', async () => {
+      const saved = await fetch(base + '/api/ops/reports', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({}),
+      }).then(readJson);
+      expect(saved.status === 200 && /^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(saved.body.id), `save ${saved.status} ${JSON.stringify(saved.body).slice(0, 120)}`);
+
+      const list = await fetch(base + '/api/ops/reports').then(readJson);
+      expect(list.status === 200 && list.body.reports.some((r) => r.id === saved.body.id), '列表缺少刚保存的报告');
+
+      // 前端提交当前 Report JSON 时应原样保存(快照),不被重新生成成规则版
+      const snapshot = {
+        meta: { period_start: '2026-10-14', period_end: '2026-10-20', title: '快照测试' },
+        kpis: [{ key: 'revenue', label: '营业额', current: 1, unit: 'CNY' }],
+        executive_summary: { overall_assessment: 'snapshot', key_findings: [] },
+        ai: { source: 'llm', model: 'test-model' },
+      };
+      const snapSaved = await fetch(base + '/api/ops/reports', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ from: '20261014', to: '20261020', report: snapshot }),
+      }).then(readJson);
+      expect(snapSaved.status === 200, `snapshot save ${snapSaved.status}`);
+      const snapList = await fetch(base + '/api/ops/reports').then(readJson);
+      const snapRow = snapList.body.reports.find((r) => r.id === snapSaved.body.id);
+      expect(snapRow && snapRow.source === 'llm', '快照来源未保留');
+
+      const one = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
+      expect(one.status === 200 && one.body.report && one.body.report.meta, '读取报告失败');
+
+      const del = await fetch(base + '/api/ops/reports/' + saved.body.id, { method: 'DELETE' }).then(readJson);
+      expect(del.status === 200 && del.body.removed === 1, `delete ${del.status}`);
+
+      const gone = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
+      expect(gone.status === 404, `expect 404, got ${gone.status}`);
+    });
+
+    await check('E2E 导入记录只含元数据', async () => {
+      const r = await fetch(base + '/api/ops/uploads').then(readJson);
+      expect(r.body.uploads.length >= 1, '记录为空');
+      expect(!Object.prototype.hasOwnProperty.call(r.body.uploads[0], 'content'), '不应返回 content');
+    });
+  } finally {
+    try { child.kill('SIGKILL'); } catch { /* ignore */ }
+  }
+
+  // ---------- 存储不可用时的降级 ----------
+  const badTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-test-bad-'));
+  const badConfig = path.join(badTmp, 'config.json');
+  fs.writeFileSync(badConfig, JSON.stringify({
+    aitable: { enabled: false },
+    storage: { driver: 'sqlite', sqlite: { file: '/dev/null/ops.db' } },
+  }));
+  const badPort = await pickPort();
+  const badChild = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
+    env: { ...process.env, PORT: String(badPort), HOST: '127.0.0.1', GENPIC_CONFIG: badConfig, GENPIC_LAKE_DIR: path.join(badTmp, 'lake'), NAPOS_CDP_PORT: '1' },
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+  const badBase = `http://127.0.0.1:${badPort}`;
+  try {
+    await waitForHealth(badPort);
+    await check('E2E 存储不可用时 summary 降级为 enabled:false', async () => {
+      const r = await fetch(badBase + '/api/ops/summary').then(readJson);
+      expect(r.status === 200, `status ${r.status}`);
+      expect(r.body.enabled === false, 'enabled 应为 false');
+      expect(!!r.body.reason, '缺少 reason');
+    });
+    await check('E2E 存储不可用时其它接口 503', async () => {
+      const r = await fetch(badBase + '/api/ops/rows').then(readJson);
+      expect(r.status === 503, `status ${r.status}`);
+    });
+    await check('E2E 存储不可用时主服务仍正常', async () => {
+      const r = await fetch(badBase + '/healthz');
+      expect(r.status === 200, `healthz ${r.status}`);
+    });
+  } finally {
+    try { badChild.kill('SIGKILL'); } catch { /* ignore */ }
+    try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
+    try { fs.rmSync(badTmp, { recursive: true, force: true }); } catch { /* ignore */ }
+  }
+
+  // ---------- 汇总 ----------
+
+  const failed = results.filter((r) => !r.ok);
+  console.log('\n========== 店铺运营测试结果 ==========');
+  for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : '  ← ' + r.error}`);
+  console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
+  if (failed.length) {
+    if (bootLog) console.log('\n服务启动日志(末尾 800 字):\n' + bootLog.slice(-800));
+    process.exit(1);
+  }
+}
+
+main().catch((e) => {
+  console.error('测试脚本自身异常:', e);
+  process.exit(1);
+});

+ 108 - 0
scripts/test-schools.js

@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+'use strict';
+
+// 学校数据(JSON 种子 + 运行时)单元测试
+// 用法:npm run test:schools
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const results = [];
+function expect(cond, msg) {
+  if (!cond) throw new Error(msg);
+}
+async function check(name, fn) {
+  try {
+    await fn();
+    results.push({ name, ok: true });
+  } catch (e) {
+    results.push({ name, ok: false, error: e.message || String(e) });
+  }
+}
+
+async function main() {
+  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'schools-test-'));
+  const seedFile = path.join(tmp, 'seed.json');
+  const runtimeFile = path.join(tmp, 'runtime.json');
+
+  fs.writeFileSync(seedFile, JSON.stringify({
+    version: 1,
+    schools: [
+      {
+        id: 'sch_seed_1', name: '内置大学', aliases: ['内置'], province: '浙江省', city: '杭州市',
+        lng: 120.1, lat: 30.2, enabled: true, seed: true,
+      },
+      {
+        id: 'sch_seed_2', name: '坐标越界大学', province: '境外',
+        lng: 10, lat: 60, enabled: true, seed: false,
+      },
+    ],
+  }));
+
+  process.env.SCHOOLS_SEED_FILE = seedFile;
+  process.env.SCHOOLS_FILE = runtimeFile;
+  const { loadSchools } = require(path.join(__dirname, '..', 'schools-store.js'));
+
+  await check('读取种子:2 条', () => {
+    const list = loadSchools();
+    expect(list.length === 2, `实际 ${list.length}`);
+    expect(list.every((s) => s.seed === true), '种子项应标记 seed');
+  });
+
+  await check('越界坐标降级为 null(未定位)', () => {
+    const s = loadSchools().find((x) => x.name === '坐标越界大学');
+    expect(s.lng === null && s.lat === null, `lng=${s.lng} lat=${s.lat}`);
+  });
+
+  await check('运行时不存在时只用种子', () => {
+    expect(!fs.existsSync(runtimeFile), '运行时文件不应存在');
+    expect(loadSchools().length === 2, '条数不对');
+  });
+
+  fs.writeFileSync(runtimeFile, JSON.stringify({
+    version: 1,
+    schools: [
+      { name: '内置大学', enabled: false, province: '浙江省' },
+      { id: 'sch_new_1', name: '新学校', province: '江苏省', city: '南京市', lng: 118.8, lat: 32.06, enabled: true },
+    ],
+  }));
+
+  await check('运行时覆盖同名种子的字段', () => {
+    const s = loadSchools().find((x) => x.name === '内置大学');
+    expect(s.enabled === false, 'enabled 未被覆盖');
+    expect(s.lng === 120.1, '未覆盖的字段应保留种子值');
+    expect(s.seed === true, '种子标记不能丢');
+  });
+
+  await check('运行时新增学校并参与排序', () => {
+    const list = loadSchools();
+    expect(list.length === 3, `实际 ${list.length}`);
+    const names = list.map((s) => s.name).join('|');
+    expect(names.includes('新学校'), names);
+  });
+
+  await check('运行时项 seed=false', () => {
+    expect(loadSchools().find((s) => s.name === '新学校').seed === false, 'seed 应为 false');
+  });
+
+  fs.writeFileSync(runtimeFile, '{ broken json');
+  await check('运行时文件损坏:退回种子且不抛错', () => {
+    const list = loadSchools();
+    expect(list.length === 2, `实际 ${list.length}`);
+    expect(list.some((s) => s.name === '内置大学'), '种子缺失');
+  });
+
+  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
+
+  const failed = results.filter((r) => !r.ok);
+  console.log('\n========== 学校数据测试结果 ==========');
+  for (const r of results) console.log(`${r.ok ? '✓' : '✗'} ${r.name}${r.ok ? '' : '  ← ' + r.error}`);
+  console.log(`\n通过 ${results.length - failed.length}/${results.length}`);
+  if (failed.length) process.exit(1);
+}
+
+main().catch((e) => {
+  console.error('测试脚本自身异常:', e);
+  process.exit(1);
+});

+ 12 - 0
server/context.js

@@ -54,6 +54,18 @@ const CONFIG_DEFAULTS = {
   },
   // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭
   autoSync: { enabled: false, time: '06:00' },
+  // 店铺运营数据(上传解析)存储:默认本地 SQLite;上线可切云端 PostgreSQL
+  storage: {
+    driver: 'sqlite',
+    sqlite: { file: 'data/ops.db' },
+    postgres: {
+      connectionString: '',
+      ssl: true,
+      max: 5,
+      schema: 'public',
+      connectTimeoutMs: 10000,
+    },
+  },
 };
 
 function writeConfigFile(cfg) {

+ 2 - 0
server/routes/index.js

@@ -10,6 +10,8 @@ function registerRoutes(app, ctx) {
   require('./generate.js').register(app, ctx);
   require('./history.js').register(app, ctx);
   require('./reports.js').register(app, ctx);
+  require('./ops.js').register(app, ctx);
+  require('./schools.js').register(app, ctx);
   require('./agent.js').register(app, ctx);
   require('./static.js').register(app, ctx);
 }

+ 1565 - 0
server/routes/ops.js

@@ -0,0 +1,1565 @@
+'use strict';
+
+// 店铺运营数据:上传解析、矩阵查询、导入记录、原件下载、重新解析、CSV 导出。
+// 存储由 ops-store 决定(默认本地 SQLite),此处只做协议与编排。
+
+const crypto = require('crypto');
+const { sendJson, readBody } = require('../util.js');
+const opsImport = require('../../ops-import.js');
+const { createOpsStore } = require('../../ops-store.js');
+const { loadSchools } = require('../../schools-store.js');
+const { makeChatCfg } = require('../context.js');
+const { chatCompletion, extractJson } = require('../agent/llm.js');
+
+const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
+const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+const PARSER_VERSION = 1;
+const BASE_FIELDS = [
+  { key: 'order_cnt', label: '订单量', type: 'int', source: 'manual' },
+  { key: 'revenue', label: '营收', type: 'money', source: 'manual' },
+  { key: 'exposure', label: '曝光量', type: 'int', source: 'manual' },
+  { key: 'click_rate', label: '进店转化率', type: 'rate', source: 'manual' },
+];
+const BASE_FIELD_KEYS = BASE_FIELDS.map((f) => f.key);
+
+function pad2(n) {
+  return String(n).padStart(2, '0');
+}
+
+function newUploadId() {
+  const d = new Date();
+  const stamp = `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
+  return `${stamp}-${crypto.randomBytes(3).toString('hex')}`;
+}
+
+function sanitizeName(name) {
+  const text = String(name || 'upload.xlsx')
+    .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
+    .trim()
+    .slice(0, 120);
+  return text || 'upload.xlsx';
+}
+
+function splitList(value) {
+  return String(value || '')
+    .split(',')
+    .map((s) => s.trim())
+    .filter(Boolean);
+}
+
+function parseFieldKeys(value) {
+  return splitList(value);
+}
+
+function parseYmd(value) {
+  const text = String(value || '').replace(/-/g, '');
+  return /^\d{8}$/.test(text) ? text : '';
+}
+
+/** 存储不可用时的统一响应(summary 例外,它要返回 200 + enabled:false) */
+async function requireHealthy(store, res) {
+  const health = await store.health();
+  if (health.ok) return true;
+  sendJson(res, 503, {
+    error: '存储不可用',
+    reason: health.reason || 'storage-unreachable',
+    detail: health.detail || '',
+  });
+  return false;
+}
+
+function normalizeFieldValue(value, type) {
+  if (value === null || value === undefined || value === '') return null;
+  if (type === 'text') return String(value);
+  const n = Number(value);
+  if (Number.isFinite(n)) return n;
+  return type === 'int' || type === 'money' || type === 'rate' ? null : String(value);
+}
+
+function fieldValue(row, field) {
+  if (field.source === 'platform') {
+    const data = row.platform || {};
+    const raw = Object.prototype.hasOwnProperty.call(data, field.label) ? data[field.label] : null;
+    return normalizeFieldValue(raw, field.type);
+  }
+  return normalizeFieldValue(row[field.key], field.type);
+}
+
+/** 按字段名与样本值推断展示类型 */
+function inferFieldType(label, samples) {
+  const name = String(label || '');
+  if (/率|占比/.test(name)) return 'rate';
+  if (/额|费|价|收入|支出|补贴|成本|销售额/.test(name)) return 'money';
+  if (/时长|时间段|时间$|列表|名称|地址|ID$|id$/.test(name)) return 'text';
+  const present = samples.filter((v) => v !== null && v !== undefined && v !== '');
+  if (!present.length) return 'text';
+  const numeric = present.filter((v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))));
+  return numeric.length === present.length ? 'int' : 'text';
+}
+
+/** 可选字段目录:4 个基础字段 + 平台导出里的全部列 */
+async function buildFieldCatalog(store) {
+  const fields = BASE_FIELDS.map((f) => ({ ...f }));
+  let columns = [];
+  let sample = [];
+  try {
+    const uploads = await store.listUploads();
+    const seen = new Set();
+    for (const upload of uploads) {
+      for (const col of ((upload.report && upload.report.columns) || [])) seen.add(col);
+    }
+    sample = (await cachedPlatformRows(store)).slice(0, 300);
+    if (!seen.size) {
+      for (const row of sample) for (const col of Object.keys(row.data || {})) seen.add(col);
+    }
+    columns = [...seen];
+  } catch { /* 存储不可用时只有基础字段 */ }
+
+  for (const column of columns) {
+    const key = `p:${column}`;
+    if (fields.some((f) => f.key === key)) continue;
+    if (['日期', '门店编号', '门店名称'].includes(column)) continue;
+    fields.push({
+      key,
+      label: column,
+      type: inferFieldType(column, sample.map((row) => (row.data || {})[column])),
+      source: 'platform',
+    });
+  }
+  return fields;
+}
+
+function buildMatrix(rows, dates, fields) {
+  const schools = new Map();
+  for (const row of rows) {
+    const schoolName = row.school || '未分组';
+    if (!schools.has(schoolName)) schools.set(schoolName, new Map());
+    const shopMap = schools.get(schoolName);
+    if (!shopMap.has(row.shopKey)) {
+      shopMap.set(row.shopKey, {
+        key: row.shopKey,
+        shopId: row.shopId,
+        shopName: row.shopName,
+        status: row.status,
+        series: {},
+        quality: {},
+        totals: {},
+      });
+    }
+    const shop = shopMap.get(row.shopKey);
+    for (const field of fields) {
+      if (!shop.series[field.key]) shop.series[field.key] = {};
+      shop.series[field.key][row.date] = fieldValue(row, field);
+    }
+    if (Array.isArray(row.quality) && row.quality.length) shop.quality[row.date] = row.quality;
+  }
+
+  const groups = [];
+  for (const [school, shopMap] of [...schools.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh-Hans-CN'))) {
+    const shops = [...shopMap.values()].sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-Hans-CN'));
+    for (const shop of shops) {
+      for (const field of fields) {
+        const series = shop.series[field.key] || {};
+        if (field.type === 'int' || field.type === 'money') {
+          let sum = 0;
+          let seen = false;
+          for (const date of dates) {
+            const value = series[date];
+            if (typeof value === 'number') { sum += value; seen = true; }
+          }
+          shop.totals[field.key] = seen ? Math.round(sum * 100) / 100 : null;
+        } else if (field.type === 'rate') {
+          let weight = 0;
+          let acc = 0;
+          const base = shop.series.exposure || {};
+          for (const date of dates) {
+            const rate = series[date];
+            const exposure = base[date];
+            if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
+              weight += exposure;
+              acc += rate * exposure;
+            }
+          }
+          shop.totals[field.key] = weight > 0 ? acc / weight : null;
+        } else {
+          shop.totals[field.key] = null;
+        }
+      }
+    }
+    groups.push({ school, shops });
+  }
+  return groups;
+}
+
+function numOrNull(value) {
+  if (value === null || value === undefined || value === '') return null;
+  const n = Number(value);
+  return Number.isFinite(n) ? n : null;
+}
+
+/** 平台全字段行 → 矩阵行:4 个基础指标另存,原始 124 字段留在 platform */
+function platformRowToMatrix(row) {
+  const d = row.data || {};
+  return {
+    date: row.date,
+    shopKey: String(row.shopId || ''),
+    shopId: String(row.shopId || ''),
+    shopName: row.shopName || '',
+    school: row.school || '',
+    status: '',
+    order_cnt: numOrNull(d['有效订单']),
+    revenue: numOrNull(d['营业额']),
+    exposure: numOrNull(d['曝光人数']),
+    click_rate: numOrNull(d['进店转化率']),
+    platform: d,
+    quality: [],
+  };
+}
+
+/** 人工表 + 平台明细合并:同一天同一店,人工的 4 个指标覆盖平台,其余平台字段保留 */
+const PLATFORM_CACHE_TTL_MS = 5 * 60 * 1000;
+const platformRowCache = new WeakMap(); // store -> { at, rows }
+
+/** 平台明细整表缓存:云端库单次拉取约 1.6MB,避免每个请求重复传输 */
+async function cachedPlatformRows(store) {
+  const hit = platformRowCache.get(store);
+  if (hit && Date.now() - hit.at < PLATFORM_CACHE_TTL_MS) return hit.rows;
+  const rows = await store.queryPlatformRows({});
+  platformRowCache.set(store, { at: Date.now(), rows });
+  return rows;
+}
+
+async function loadMergedRows(store, { from, to, schools, shops } = {}) {
+  let platform = [];
+  try {
+    platform = await cachedPlatformRows(store);
+  } catch { /* 平台表不可用不影响人工数据 */ }
+  if (from) platform = platform.filter((row) => row.date >= from);
+  if (to) platform = platform.filter((row) => row.date <= to);
+  const manual = await store.queryRows({ from, to, schools, shops });
+
+  const map = new Map();
+  for (const row of platform) map.set(`${row.date}|${row.shopId}`, platformRowToMatrix(row));
+  for (const row of manual) {
+    const key = `${row.date}|${row.shopKey}`;
+    const base = map.get(key);
+    map.set(key, { ...(base || {}), ...row, platform: base ? base.platform : null });
+  }
+
+  let rows = [...map.values()];
+  if (schools && schools.length) rows = rows.filter((r) => schools.includes(String(r.school || '')));
+  if (shops && shops.length) rows = rows.filter((r) => shops.includes(String(r.shopId || r.shopKey || '')));
+  return rows;
+}
+
+function addDays(ymd, n) {
+  const d = new Date(Date.UTC(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8))));
+  d.setUTCDate(d.getUTCDate() + n);
+  return `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`;
+}
+
+function dayCount(from, to) {
+  const a = Date.UTC(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)));
+  const b = Date.UTC(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)));
+  return Math.max(1, Math.round((b - a) / 86400000) + 1);
+}
+
+function pctDelta(cur, prev) {
+  if (prev == null || prev === 0 || cur == null) return null;
+  return Math.round(((cur - prev) / prev) * 1000) / 10;
+}
+
+function sumSeries(rows, key) {
+  let sum = 0;
+  for (const row of rows) if (typeof row[key] === 'number') sum += row[key];
+  return sum;
+}
+
+function platformNum(row, column) {
+  if (!row.platform) return null;
+  return numOrNull(row.platform[column]);
+}
+
+/** 零单门店诊断:用平台字段区分「未开业 / 闭店 / 无曝光 / 无进店 / 无转化」 */
+function diagnoseZeroOrder(row) {
+  const p = row.platform;
+  const orders = Number(row.order_cnt);
+  if (!(orders === 0)) return null;
+  if (!p) {
+    return { reason: 'unknown', reasonText: '缺少平台明细,无法判断' };
+  }
+  const bizHours = String(p['营业时长'] == null ? '' : p['营业时长']).trim();
+  const zeroBiz = /^0\s*小时\s*0\s*分$/.test(bizHours) || bizHours === '0' || bizHours === '';
+  const abnormal = numOrNull(p['异常关店时长']) || 0;
+  const exposure = numOrNull(p['曝光人数']);
+  const visit = numOrNull(p['进店人数']);
+  const validShop = String(p['是否有效门店'] || '').trim();
+
+  if (zeroBiz) {
+    if (validShop === '否') {
+      return { reason: 'not-open', reasonText: '未开业(非有效门店,当日无营业时长)', bizHours, abnormal, exposure, visit, validShop };
+    }
+    if (abnormal > 0) {
+      return { reason: 'closed', reasonText: `当日闭店(异常关店 ${abnormal} 分钟,无营业时长)`, bizHours, abnormal, exposure, visit, validShop };
+    }
+    return { reason: 'not-open', reasonText: '未营业(当日无营业时长,也未记录异常关店)', bizHours, abnormal, exposure, visit, validShop };
+  }
+  if (exposure === 0) {
+    return { reason: 'no-traffic', reasonText: '营业中但无曝光(未进流量池)', bizHours, abnormal, exposure, visit, validShop };
+  }
+  if (visit === 0) {
+    return { reason: 'no-visit', reasonText: `有曝光 ${exposure} 但无进店`, bizHours, abnormal, exposure, visit, validShop };
+  }
+  return { reason: 'no-conversion', reasonText: `有进店 ${visit} 但零下单(转化问题)`, bizHours, abnormal, exposure, visit, validShop };
+}
+
+/** 一次聚合:KPI 口径集中在这里,前端不再二次计算 */
+function aggregateRows(rows) {
+  let revenue = 0;
+  let orders = 0;
+  let exposure = 0;
+  let visit = 0;
+  let buyers = 0;
+  let expense = 0;
+  let subsidy = 0;
+  let ratingSum = 0;
+  let ratingCount = 0;
+  let rateWeight = 0;
+  let rateAcc = 0;
+
+  for (const row of rows) {
+    if (typeof row.revenue === 'number') revenue += row.revenue;
+    if (typeof row.order_cnt === 'number') orders += row.order_cnt;
+    if (typeof row.exposure === 'number') exposure += row.exposure;
+    if (typeof row.click_rate === 'number' && row.exposure > 0) {
+      rateWeight += row.exposure;
+      rateAcc += row.click_rate * row.exposure;
+    }
+    const v = platformNum(row, '进店人数');
+    if (v != null) visit += v;
+    const b = platformNum(row, '下单人数');
+    if (b != null) buyers += b;
+    const e = platformNum(row, '支出');
+    if (e != null) expense += e;
+    const sub = platformNum(row, '活动总补贴');
+    if (sub != null) subsidy += sub;
+    const rating = platformNum(row, '店铺评分');
+    if (rating != null && rating > 0) {
+      ratingSum += rating;
+      ratingCount += 1;
+    }
+  }
+
+  const conversion = visit > 0 && exposure > 0
+    ? visit / exposure
+    : (rateWeight > 0 ? rateAcc / rateWeight : null);
+
+  return {
+    revenue: Math.round(revenue * 100) / 100,
+    orders,
+    exposure,
+    visit,
+    buyers,
+    expense: Math.round(expense * 100) / 100,
+    subsidy: Math.round(subsidy * 100) / 100,
+    rating: ratingCount ? Math.round((ratingSum / ratingCount) * 100) / 100 : null,
+    avgTicket: orders > 0 ? Math.round((revenue / orders) * 100) / 100 : null,
+    conversion: conversion == null ? null : Math.round(conversion * 10000) / 10000,
+  };
+}
+
+async function buildDashboard(store, { from, to, schools, shops }) {
+  const filters = { schools, shops };
+  let rows = await loadMergedRows(store, { from, to, ...filters });
+  const dates = [...new Set(rows.map((r) => r.date))].sort();
+  const start = from || dates[0];
+  const end = to || dates[dates.length - 1];
+
+  if (!start || !end) {
+    return { from: null, to: null, prev: null, kpis: null, trend: [], ranking: [], funnel: null, structure: [] };
+  }
+
+  // 未指定区间时按实际数据范围收敛
+  if (!from || !to) rows = await loadMergedRows(store, { from: start, to: end, ...filters });
+
+  const span = dayCount(start, end);
+  const prevTo = addDays(start, -1);
+  const prevFrom = addDays(prevTo, -(span - 1));
+  let prevRows = [];
+  try {
+    prevRows = await loadMergedRows(store, { from: prevFrom, to: prevTo, ...filters });
+  } catch { /* 上期缺失不影响本期 */ }
+
+  const cur = aggregateRows(rows);
+  const prev = prevRows.length ? aggregateRows(prevRows) : null;
+
+  const kpi = (value, prevValue, unit, isRate) => ({
+    value,
+    prev: prevValue,
+    delta: prevValue == null || value == null
+      ? null
+      : (isRate ? Math.round((value - prevValue) * 1000) / 10 : pctDelta(value, prevValue)),
+    unit,
+  });
+
+  const kpis = {
+    revenue: kpi(cur.revenue, prev && prev.revenue, 'money'),
+    orders: kpi(cur.orders, prev && prev.orders, 'int'),
+    avgTicket: kpi(cur.avgTicket, prev && prev.avgTicket, 'money'),
+    exposure: kpi(cur.exposure, prev && prev.exposure, 'int'),
+    conversion: kpi(cur.conversion, prev && prev.conversion, 'pp', true),
+    rating: { value: cur.rating, prev: null, delta: null, unit: 'num' },
+  };
+
+  const byDate = new Map();
+  const byShop = new Map();
+  for (const row of rows) {
+    if (!byDate.has(row.date)) byDate.set(row.date, []);
+    byDate.get(row.date).push(row);
+    const key = String(row.shopId || row.shopKey || '');
+    if (!key) continue;
+    if (!byShop.has(key)) byShop.set(key, { shopId: key, shopName: row.shopName || key, school: row.school || '', rows: [] });
+    byShop.get(key).rows.push(row);
+  }
+
+  const trend = [...byDate.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([date, list]) => {
+    const agg = aggregateRows(list);
+    return { date, revenue: agg.revenue, orders: agg.orders };
+  });
+
+  const allShops = [...byShop.values()]
+    .map((shop) => {
+      const agg = aggregateRows(shop.rows);
+      return { shopId: shop.shopId, shopName: shop.shopName, school: shop.school, revenue: agg.revenue, orders: agg.orders };
+    })
+    .sort((a, b) => b.revenue - a.revenue);
+  const ranking = allShops.slice(0, 10);
+  const bottom = allShops.slice(-5).reverse();
+
+  const funnel = {
+    exposure: cur.exposure,
+    visit: cur.visit,
+    order: cur.buyers || cur.orders,
+  };
+
+  const zeroOrder = [];
+  for (const row of rows) {
+    const diagnosis = diagnoseZeroOrder(row);
+    if (!diagnosis) continue;
+    zeroOrder.push({
+      date: row.date,
+      shopId: row.shopId,
+      shopName: row.shopName,
+      school: row.school || '',
+      orders: 0,
+      ...diagnosis,
+    });
+  }
+  const zeroOrderSummary = zeroOrder.reduce((acc, item) => {
+    acc[item.reason] = (acc[item.reason] || 0) + 1;
+    return acc;
+  }, {});
+
+  const structure = [...byDate.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([date, list]) => {
+    const agg = aggregateRows(list);
+    return { date, income: agg.revenue, expense: agg.expense, subsidy: agg.subsidy };
+  });
+
+  return {
+    from: start,
+    to: end,
+    prev: prevRows.length ? { from: prevFrom, to: prevTo } : null,
+    kpis,
+    trend,
+    ranking,
+    bottom,
+    zeroOrder,
+    zeroOrderSummary,
+    shopCount: allShops.length,
+    funnel,
+    structure,
+  };
+}
+
+const INSIGHT_SYSTEM_PROMPT = [
+  '你是连锁餐饮公司的经营分析师。用户会给一段 JSON,包含某段时间的经营汇总(kpis)、按日趋势(trend)、门店排行(ranking)与垫底(bottom)、转化漏斗(funnel)、收入支出结构(structure)。',
+  '请用中文输出 3~5 条要点,每行一条,以「- 」开头。要求:',
+  '1. 先给结论,再给依据;',
+  '2. 必须引用 JSON 里的具体数字(金额保留两位小数、人数/单量用整数、比率用百分比);',
+  '3. 指出风险(下滑、零单门店、成本占比高、漏斗转化偏低等),并给一句可执行建议;',
+  '4. 数据不足(如 prev 为 null、只有 1 天)就直接说明,不要编造;',
+  '5. 若 zeroOrder 非空,必须用一条要点按原因归类汇总(未开业/闭店/无曝光/无进店/无转化各多少店次),并点明哪一类需要运营介入;不要笼统说「零单门店」;',
+  '6. 每条不超过 60 字,不要输出 JSON、标题或前后缀。',
+].join('\n');
+
+function insightContext(data) {
+  const k = (key) => (data.kpis[key] ? { value: data.kpis[key].value, delta: data.kpis[key].delta } : null);
+  const rate = (a, b) => (b > 0 ? Math.round((a / b) * 1000) / 10 : null);
+  const funnel = data.funnel || {};
+  return {
+    range: { from: data.from, to: data.to, days: data.trend.length, prev: data.prev },
+    kpis: {
+      revenue: k('revenue'),
+      orders: k('orders'),
+      avgTicket: k('avgTicket'),
+      exposure: k('exposure'),
+      conversion: k('conversion'),
+      rating: k('rating'),
+    },
+    funnel: {
+      exposure: funnel.exposure,
+      visit: funnel.visit,
+      order: funnel.order,
+      visitRate: rate(funnel.visit, funnel.exposure),
+      orderRate: rate(funnel.order, funnel.visit),
+    },
+    trend: data.trend.slice(-14),
+    shopCount: data.shopCount,
+    topShops: (data.ranking || []).slice(0, 5).map((s) => ({ name: s.shopName, revenue: s.revenue, orders: s.orders })),
+    bottomShops: (data.bottom || []).map((s) => ({ name: s.shopName, revenue: s.revenue, orders: s.orders })),
+    zeroOrderSummary: data.zeroOrderSummary || {},
+    zeroOrder: (data.zeroOrder || []).slice(0, 20).map((z) => ({
+      date: z.date,
+      shop: z.shopName,
+      reason: z.reason,
+      reasonText: z.reasonText,
+    })),
+    structure: (data.structure || []).map((s) => ({
+      date: s.date,
+      income: s.income,
+      expense: s.expense,
+      subsidy: s.subsidy,
+      expenseRatio: s.income > 0 ? Math.round((s.expense / s.income) * 1000) / 10 : null,
+    })),
+  };
+}
+
+function parseInsightPoints(text) {
+  return String(text || '')
+    .split(/\r?\n/)
+    .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.、)])\s*/, '').trim())
+    .filter(Boolean)
+    .slice(0, 6);
+}
+
+/** 由聚合结果生成 Report JSON(图文 / 交互 / PDF 三版共用;数值全部来自后端计算) */
+function buildReport(data, meta = {}) {
+  const { kpis, trend, ranking, bottom, zeroOrder, funnel, structure, from, to, prev, shopCount } = data;
+  const money = (v) => (v == null ? null : Math.round(v * 100) / 100);
+  const pct = (v) => (v == null ? null : Math.round(v * 1000) / 10);
+  const findings = [];
+
+  const rev = kpis.revenue || {};
+  if (rev.delta != null) {
+    findings.push({
+      severity: rev.delta < -5 ? 'warning' : rev.delta > 5 ? 'positive' : 'neutral',
+      finding: `营业额 ${money(rev.value)} 元,较上期 ${rev.delta > 0 ? '增长' : '下降'} ${Math.abs(rev.delta)}%`,
+      evidence: [`营业额:${money(rev.value)}`, `上期营业额:${money(rev.prev)}`],
+      interpretation: rev.delta < 0 ? '规模收缩,需要结合订单量与流量拆解原因' : '规模增长,关注是否由活动或流量投放带来',
+      confidence: 'high',
+    });
+  }
+
+  const conv = kpis.conversion || {};
+  if (conv.value != null) {
+    findings.push({
+      severity: conv.delta != null && conv.delta < -0.5 ? 'warning' : 'neutral',
+      finding: `进店转化率 ${pct(conv.value)}%${conv.delta != null ? `,较上期 ${conv.delta > 0 ? '+' : ''}${conv.delta}pp` : ''}`,
+      evidence: [`进店转化率:${pct(conv.value)}%`],
+      interpretation: '转化率反映进店后的下单意愿,需结合定价、售罄与出餐时长判断',
+      confidence: 'medium',
+    });
+  }
+
+  const zeroByReason = (zeroOrder || []).reduce((acc, item) => {
+    acc[item.reason] = acc[item.reason] || [];
+    acc[item.reason].push(item);
+    return acc;
+  }, {});
+  const zeroTotal = (zeroOrder || []).length;
+  if (zeroTotal) {
+    const parts = Object.entries(zeroByReason).map(([reason, list]) => `${REASON_LABEL[reason] || reason} ${list.length} 店次`);
+    findings.push({
+      severity: (zeroByReason['no-conversion'] || []).length ? 'warning' : 'neutral',
+      finding: `零单合计 ${zeroTotal} 店次:${parts.join('、')}`,
+      evidence: [`零单店次:${zeroTotal}`],
+      interpretation: '未开业/闭店属于营业状态问题;有进店无下单才是转化问题,需要优先处理',
+      confidence: 'high',
+    });
+  }
+
+  if (ranking && ranking.length && rev.value > 0) {
+    const share = Math.round((ranking[0].revenue / rev.value) * 1000) / 10;
+    findings.push({
+      severity: share > 40 ? 'warning' : 'neutral',
+      finding: `头部门店「${ranking[0].shopName}」贡献营业额 ${money(ranking[0].revenue)} 元,占比 ${share}%`,
+      evidence: [`头部门店:${ranking[0].shopName}(${money(ranking[0].revenue)} 元)`],
+      interpretation: share > 40 ? '集中度偏高,单店波动会显著影响大盘' : '门店贡献相对分散',
+      confidence: 'high',
+    });
+  }
+
+  if (!prev) {
+    findings.push({
+      severity: 'neutral',
+      finding: '所选区间之前没有可比数据,本期不计算环比趋势',
+      evidence: ['上期数据:无'],
+      interpretation: '只能做横向门店对比与当日诊断,不要据此判断长期趋势',
+      confidence: 'high',
+    });
+  }
+
+  const alerts = [];
+  for (const [reason, list] of Object.entries(zeroByReason)) {
+    alerts.push({
+      severity: reason === 'no-conversion' ? 'P1' : 'P2',
+      store_id: list[0].shopId || null,
+      title: `${REASON_LABEL[reason] || reason}:${list.length} 店次`,
+      evidence: list.slice(0, 5).map((z) => `${formatYmd(z.date)} ${z.shopName}:${z.reasonText}`),
+      hypotheses: reason === 'no-conversion'
+        ? ['菜品/定价与周边相比缺乏竞争力', '出餐或配送时长导致流失', '活动力度不足或库存售罄']
+        : ['门店未上线或当日排班缺失', '平台侧闭店/审核状态异常'],
+      next_checks: reason === 'no-conversion'
+        ? ['核对当日菜品售罄记录', '查看差评与退款原因', '对比同商圈同类门店客单价']
+        : ['确认门店上线状态与营业时间设置', '核对平台闭店记录'],
+    });
+  }
+  if (conv.delta != null && conv.delta < -0.5) {
+    alerts.push({
+      severity: 'P1', store_id: null, title: '进店转化率下滑',
+      evidence: [`本期进店转化率:${pct(conv.value)}%`, `上期进店转化率:${pct(conv.prev)}%`],
+      hypotheses: ['菜单改价或活动结束', '高峰期出餐变慢', '竞品补贴'],
+      next_checks: ['对比改价/活动时间', '查看出餐时长与超时单'],
+    });
+  }
+
+  const actionPlan = [];
+  if ((zeroByReason['no-conversion'] || []).length) {
+    actionPlan.push({
+      priority: 'P0',
+      action: '对「有进店无下单」门店做单店诊断:菜品可售、价格带、出餐与配送时长',
+      owner_role: '运营负责人',
+      time_horizon: '24 小时内',
+      success_metric: '次日进店→下单转化率回升',
+      related_alerts: ['有进店无下单'],
+    });
+  }
+  if ((zeroByReason['not-open'] || []).length || (zeroByReason['closed'] || []).length) {
+    actionPlan.push({
+      priority: 'P1',
+      action: '核对未开业/闭店门店的上线状态、营业时间与异常关店记录',
+      owner_role: '门店督导',
+      time_horizon: '48 小时内',
+      success_metric: '营业时长恢复、零单店次下降',
+      related_alerts: ['未开业', '当日闭店'],
+    });
+  }
+  if (conv.delta != null && conv.delta < -0.5) {
+    actionPlan.push({
+      priority: 'P1',
+      action: '排查转化率下滑:活动是否结束、是否改价、是否有售罄',
+      owner_role: '运营负责人',
+      time_horizon: '72 小时内',
+      success_metric: '转化率回到上期水平',
+      related_alerts: ['进店转化率下滑'],
+    });
+  }
+  if (prev) {
+    actionPlan.push({
+      priority: 'P2',
+      action: '补全成本与结算口径,再评估毛利与真实利润',
+      owner_role: '财务',
+      time_horizon: '2 周内',
+      success_metric: '可计算毛利与费用率',
+      related_alerts: [],
+    });
+  }
+
+  const kpiList = [
+    ['revenue', '营业额', 'CNY', '平台「营业额」列求和'],
+    ['orders', '有效订单', '单', '平台「有效订单」列求和'],
+    ['avgTicket', '客单价', 'CNY', '营业额 ÷ 有效订单'],
+    ['exposure', '曝光人数', '人', '平台「曝光人数」列求和'],
+    ['conversion', '进店转化率', '%', 'Σ进店人数 ÷ Σ曝光人数'],
+    ['rating', '店铺评分', '分', '有效评分门店均值(排除 0 分)'],
+  ].map(([key, label, unit, definition]) => {
+    const item = kpis[key] || {};
+    const fmt = (value) => {
+      if (value == null) return null;
+      if (unit === '%') return pct(value);
+      if (unit === '单' || unit === '人') return Math.round(value);
+      return money(value);
+    };
+    return {
+      key, label, unit, definition,
+      current: fmt(item.value),
+      previous: fmt(item.prev),
+      difference: item.value != null && item.prev != null && unit !== '%'
+        ? Math.round((item.value - item.prev) * 100) / 100
+        : (item.value != null && item.prev != null ? Math.round((item.value - item.prev) * 1000) / 10 : null),
+      change_pct: item.delta,
+      status: item.delta == null ? 'unknown' : item.delta > 0 ? 'up' : item.delta < 0 ? 'down' : 'flat',
+    };
+  });
+
+  return {
+    meta: {
+      title: meta.title || '经营分析报告',
+      organization: meta.organization || '',
+      period_start: formatYmd(from),
+      period_end: formatYmd(to),
+      generated_at: new Date().toISOString(),
+      currency: 'CNY',
+      data_scope: `${shopCount} 家门店 · ${trend.length} 天 · ${zeroTotal} 个零单店次`,
+      source_files: meta.sourceFiles || [],
+    },
+    executive_summary: {
+      overall_assessment: rev.delta == null
+        ? '本期有经营数据,但缺少可比的上期,暂不做趋势判断;以下结论基于本期横向对比与门店诊断。'
+        : `本期营业额 ${money(rev.value)} 元,较上期${rev.delta >= 0 ? '增长' : '下降'} ${Math.abs(rev.delta)}%,订单 ${kpis.orders.value} 单。`,
+      key_findings: findings,
+    },
+    kpis: kpiList,
+    sections: [
+      { id: 'overview', title: '经营概览', summary: rev.delta == null ? '本期无上期对比' : `营业额环比 ${rev.delta}%`, paragraphs: [], chart_ids: ['chart_trend'], table_ids: [], evidence_keys: ['revenue', 'orders'] },
+      { id: 'stores', title: '门店表现', summary: `共 ${shopCount} 家门店,Top1 为「${(ranking[0] || {}).shopName || '—'}」`, paragraphs: [], chart_ids: ['chart_rank'], table_ids: ['table_stores'], evidence_keys: ['revenue'] },
+      { id: 'funnel', title: '流量与转化', summary: `曝光 ${funnel.exposure} → 进店 ${funnel.visit} → 下单 ${funnel.order}`, paragraphs: [], chart_ids: ['chart_funnel'], table_ids: [], evidence_keys: ['conversion'] },
+      { id: 'finance', title: '收入与费用', summary: '仅展示平台收入、支出与补贴,不等同于利润', paragraphs: [], chart_ids: ['chart_structure'], table_ids: [], evidence_keys: ['revenue'] },
+      { id: 'quality', title: '数据质量', summary: '', paragraphs: [], chart_ids: [], table_ids: [], evidence_keys: [] },
+    ],
+    stores: (ranking || []).slice(0, 10).map((shop, index) => ({
+      store_id: shop.shopId,
+      name: shop.shopName,
+      rank: index + 1,
+      facts: [`营业额 ${money(shop.revenue)} 元,订单 ${shop.orders} 单`],
+      diagnosis: index === 0 ? '本期贡献最高,需保持供给与活动稳定' : '贡献靠后,结合流量与转化排查',
+      risks: [],
+      recommendations: [],
+    })),
+    charts: [
+      { id: 'chart_trend', type: 'line_bar', title: '营业额与订单趋势', description: '柱为营业额,线为订单量;天数较少时不做平滑趋势判断。', source: '平台明细按日聚合', unit: 'CNY', data: trend },
+      { id: 'chart_rank', type: 'horizontal_bar', title: '门店营业额 Top 10', description: '按所选区间营业额排序。', source: '平台明细按门店聚合', unit: 'CNY', data: ranking },
+      { id: 'chart_funnel', type: 'funnel', title: '曝光 → 进店 → 下单', description: '三层人数不可跨口径混算。', source: '平台「曝光人数/进店人数/下单人数」', unit: '人', data: funnel },
+      { id: 'chart_structure', type: 'bar', title: '收入与支出结构', description: '收入为营业额;支出与补贴按平台口径。', source: '平台「营业额/支出/活动总补贴」', unit: 'CNY', data: structure },
+    ],
+    tables: [{
+      id: 'table_stores',
+      title: '门店明细(Top 10)',
+      columns: [
+        { key: 'name', label: '门店' }, { key: 'revenue', label: '营业额' },
+        { key: 'orders', label: '订单' }, { key: 'share', label: '占比' },
+      ],
+      rows: (ranking || []).map((shop) => ({
+        name: shop.shopName, revenue: money(shop.revenue), orders: shop.orders,
+        share: rev.value > 0 ? Math.round((shop.revenue / rev.value) * 1000) / 10 : null,
+      })),
+      footnote: '占比 = 门店营业额 ÷ 本期总营业额',
+    }],
+    alerts,
+    action_plan: actionPlan,
+    data_quality: {
+      coverage: `${formatYmd(from)} ~ ${formatYmd(to)},共 ${trend.length} 天`,
+      warnings: prev ? [] : ['所选区间前无数据,环比不可用'],
+      comparability: '同店对比要求门店集合一致;本期未做同店筛选',
+      missing_metrics: ['成本、平台佣金明细缺失,无法计算毛利与净利'],
+    },
+    methodology: [
+      '金额与单量均由后端按「日期+门店」明细求和,不由模型生成',
+      '进店转化率 = Σ进店人数 ÷ Σ曝光人数(同层级口径)',
+      '客单价 = 营业额 ÷ 有效订单,订单为 0 时为 null',
+      '上期为紧邻的等长区间,比率变化用百分点(pp)',
+    ],
+  };
+}
+
+const REPORT_AI_PROMPT = [
+  '你是连锁餐饮公司的经营分析师。用户会给一段已经计算好的经营数据 JSON(含 kpis/trend/ranking/bottom/funnel/structure/zeroOrder)。',
+  '数值全部由后端计算,你不得修改、杜撰或重算;只做解释、诊断与建议。',
+  '只输出一个 JSON 对象,不要代码块、不要前后缀,结构如下:',
+  '{"overall_assessment":"一段客观判断",',
+  ' "key_findings":[{"severity":"positive|neutral|warning|critical","finding":"结论","evidence":["指标:数值"],"interpretation":"解释","confidence":"high|medium|low"}],',
+  ' "sections":[{"id":"overview|stores|funnel|finance","summary":"本节结论","paragraphs":["事实与解释"]}],',
+  ' "alerts":[{"severity":"P0|P1|P2","title":"告警标题","evidence":["数据证据"],"hypotheses":["待验证原因"],"next_checks":["核查步骤"]}],',
+  ' "action_plan":[{"priority":"P0|P1|P2","action":"具体行动","owner_role":"负责人角色","time_horizon":"时间窗","success_metric":"可衡量指标"}]}',
+  '要求:3~5 条 findings、2~4 条 alerts、1~4 条 action;金额保留两位小数,比率用百分比;',
+  'zeroOrder 非空时必须按原因归类(未开业/闭店/无曝光/无进店/无转化各多少店次)并点明哪类需要运营介入;',
+  '没有上期数据时明确说明不可做趋势判断;不输出「赋能」「抓手」等套话。',
+  '术语必须与指标名一致:revenue 是「营业额」(不是收入/结算收入/利润),avgTicket 是「客单价」,conversion 是「进店转化率」;日期一律写成 YYYY-MM-DD。',
+  'evidence 必须写成「中文指标名:数值」(如「营业额:14926.92」「零单店次:11」),禁止出现 zeroOrder、revenue、top_shop 等内部字段名。',
+].join('\n');
+
+const EVIDENCE_CN = {
+  revenue: '营业额', orders: '有效订单', avgTicket: '客单价', exposure: '曝光人数',
+  conversion: '进店转化率', rating: '店铺评分', zeroOrder: '零单店次',
+  previous: '上期值', prev: '上期值', top_shop: '头部门店', shop_id: '门店ID',
+  visit: '进店人数', buyers: '下单人数', expense: '支出', subsidy: '补贴',
+};
+
+/** 把模型偶发的内部字段名(zeroOrder/revenue 等)翻成中文,普通用户才看得懂 */
+function humanizeText(value) {
+  return String(value == null ? '' : value)
+    .replace(/\b(zeroOrder|revenue|orders|avgTicket|exposure|conversion|rating|previous|prev|top_shop|shop_id|visit|buyers|expense|subsidy)\b(:?)/g,
+      (match, key, colon) => EVIDENCE_CN[key] + (colon || ''));
+}
+
+function humanizeEvidence(list) {
+  return (list || []).map(humanizeText);
+}
+
+/** 把模型返回的叙述字段覆盖到规则版报告上;结构不合法就返回 null(保留规则版) */
+function overlayAiReport(base, ai, meta = {}) {
+  if (!ai || typeof ai !== 'object') return null;
+  const report = JSON.parse(JSON.stringify(base));
+  let touched = false;
+
+  if (typeof ai.overall_assessment === 'string' && ai.overall_assessment.trim()) {
+    report.executive_summary.overall_assessment = ai.overall_assessment.trim();
+    touched = true;
+  }
+  if (Array.isArray(ai.key_findings) && ai.key_findings.length) {
+    const findings = ai.key_findings
+      .filter((f) => f && f.finding)
+      .slice(0, 6)
+      .map((f) => ({
+        severity: ['positive', 'neutral', 'warning', 'critical'].includes(f.severity) ? f.severity : 'neutral',
+        finding: humanizeText(f.finding).slice(0, 120),
+        evidence: humanizeEvidence(f.evidence).slice(0, 5),
+        interpretation: f.interpretation ? humanizeText(f.interpretation).slice(0, 200) : '',
+        confidence: ['high', 'medium', 'low'].includes(f.confidence) ? f.confidence : 'medium',
+      }));
+    if (findings.length) {
+      report.executive_summary.key_findings = findings;
+      touched = true;
+    }
+  }
+  if (Array.isArray(ai.sections)) {
+    const byId = new Map(report.sections.map((sec) => [sec.id, sec]));
+    for (const sec of ai.sections) {
+      const target = sec && byId.get(sec.id);
+      if (!target) continue;
+      if (sec.summary) target.summary = String(sec.summary).slice(0, 200);
+      if (Array.isArray(sec.paragraphs) && sec.paragraphs.length) {
+        target.paragraphs = sec.paragraphs.slice(0, 4).map((t) => String(t).slice(0, 400));
+      }
+      touched = true;
+    }
+  }
+  if (Array.isArray(ai.alerts) && ai.alerts.length) {
+    report.alerts = ai.alerts.slice(0, 5).map((a) => ({
+      severity: ['P0', 'P1', 'P2'].includes(a.severity) ? a.severity : 'P2',
+      store_id: a.store_id || null,
+      title: humanizeText(a.title || '').slice(0, 80),
+      evidence: humanizeEvidence(a.evidence).slice(0, 6),
+      hypotheses: Array.isArray(a.hypotheses) ? a.hypotheses.slice(0, 4).map(String) : [],
+      next_checks: Array.isArray(a.next_checks) ? a.next_checks.slice(0, 4).map(String) : [],
+    }));
+    touched = true;
+  }
+  if (Array.isArray(ai.action_plan) && ai.action_plan.length) {
+    report.action_plan = ai.action_plan.slice(0, 4).map((p) => ({
+      priority: ['P0', 'P1', 'P2'].includes(p.priority) ? p.priority : 'P2',
+      action: String(p.action || '').slice(0, 160),
+      owner_role: String(p.owner_role || '').slice(0, 40),
+      time_horizon: String(p.time_horizon || '').slice(0, 40),
+      success_metric: String(p.success_metric || '').slice(0, 80),
+      related_alerts: Array.isArray(p.related_alerts) ? p.related_alerts : [],
+    }));
+    touched = true;
+  }
+  if (!touched) return null;
+  report.ai = { source: 'llm', model: meta.model || '', generatedAt: new Date().toISOString() };
+  return report;
+}
+
+const REASON_LABEL = { 'not-open': '未开业', closed: '当日闭店', 'no-traffic': '无曝光', 'no-visit': '无进店', 'no-conversion': '有进店无下单', unknown: '数据不足' };
+
+function formatYmd(ymd) {
+  const s = String(ymd || '');
+  return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
+}
+
+function csvCell(value) {
+  const text = value == null ? '' : String(value);
+  return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
+}
+
+/** 从文件名识别已登记学校(取最长匹配,如「浙江工商桐庐校区20261018…」→ 浙江工商大学桐庐校区) */
+function resolveSchoolFromFileName(fileName) {
+  let best = '';
+  let bestName = '';
+  for (const school of loadSchools()) {
+    for (const candidate of [school.name, ...(school.aliases || [])]) {
+      const name = String(candidate || '').trim();
+      if (name && fileName.includes(name) && name.length > bestName.length) {
+        best = school.name;
+        bestName = name;
+      }
+    }
+  }
+  return best;
+}
+
+function register(app, ctx) {
+  const store = createOpsStore(ctx.config);
+
+  // ---------- 上传 ----------
+
+  app.post('/api/ops/upload', async ({ req, res }) => {
+    const body = await readBody(req);
+    const fileName = sanitizeName(body.fileName);
+    if (!/\.xlsx$/i.test(fileName)) {
+      return sendJson(res, 400, { error: '只支持 .xlsx 文件' });
+    }
+    const base64 = String(body.contentBase64 || '').replace(/^data:[^;]+;base64,/, '');
+    if (!base64) return sendJson(res, 400, { error: '缺少文件内容' });
+    let content;
+    try {
+      content = Buffer.from(base64, 'base64');
+    } catch {
+      return sendJson(res, 400, { error: 'base64 解码失败' });
+    }
+    if (!content.length) return sendJson(res, 400, { error: '文件内容为空' });
+    if (content.length > MAX_UPLOAD_BYTES) {
+      return sendJson(res, 413, { error: '文件超过 10MB 上限' });
+    }
+    if (!(await requireHealthy(store, res))) return;
+
+    const sha256 = crypto.createHash('sha256').update(content).digest('hex');
+    const existing = await store.findUploadBySha(sha256);
+    if (!body.force) {
+      if (existing) {
+        return sendJson(res, 409, { error: '该文件已导入过', uploadId: existing, duplicate: true });
+      }
+    }
+
+    const year = Number(body.year) || new Date().getFullYear();
+    let school = String(body.school || '').trim().slice(0, 60);
+    let parsed;
+    try {
+      parsed = opsImport.parseAny(content, { year, school });
+    } catch (e) {
+      return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
+    }
+    if (!parsed.rows.length) {
+      return sendJson(res, 400, { error: '未解析到有效数据行' });
+    }
+
+    const uploadId = newUploadId();
+    const uploadedAt = new Date().toISOString();
+    const from = parsed.dates[0] || '';
+    const to = parsed.dates[parsed.dates.length - 1] || '';
+
+    // ---------- 平台导出(全字段明细) ----------
+    if (parsed.kind === 'platform-export') {
+      // 表里没有学校列:优先用上传时选定的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」)
+      if (!school) school = resolveSchoolFromFileName(fileName);
+      if (!school) {
+        return sendJson(res, 400, { error: '请选择学校(平台导出表里没有学校列)', code: 'OPS_SCHOOL_REQUIRED' });
+      }
+
+      const existingRows = await store.queryPlatformRows({ from, to });
+      const known = new Map(existingRows.map((r) => [`${r.date}|${r.shopId}`, r]));
+      let newRows = 0;
+      let identicalRows = 0;
+      let conflictRows = 0;
+      const incoming = [];
+      for (const row of parsed.rows) {
+        const key = `${row.date}|${row.shopId}`;
+        const old = known.get(key);
+        if (!old) { newRows += 1; incoming.push(row); continue; }
+        const sameData = JSON.stringify(old.data) === JSON.stringify(row.data);
+        if (sameData && old.school === school) { identicalRows += 1; continue; }
+        conflictRows += 1;
+        if (body.force) incoming.push(row);
+      }
+      const toWrite = body.force ? parsed.rows : incoming;
+      const report = {
+        kind: 'platform-export',
+        rows: parsed.rows.length,
+        shops: parsed.shops.length,
+        newRows,
+        identicalRows,
+        conflictRows,
+        writtenRows: toWrite.length,
+        warningCount: parsed.warnings.length,
+        dates: { from, to },
+        year,
+        schoolOverride: school,
+        parser: { kind: 'platform-export', version: PARSER_VERSION },
+        columns: parsed.columns || [],
+        warnings: parsed.warnings.slice(0, 500),
+      };
+
+      try {
+        await store.withTransaction(async (tx) => {
+          // 事务内二次校验:并发同名文件只允许一个写入
+          if (!body.force && await tx.findUploadBySha(sha256)) {
+            const err = new Error('duplicate');
+            err.code = 'OPS_DUPLICATE';
+            throw err;
+          }
+          if (body.force) await tx.deletePlatformRowsBySha(sha256);
+          await tx.insertUpload({
+            id: uploadId,
+            originalName: fileName,
+            sha256,
+            sizeBytes: content.length,
+            uploadedAt,
+            year,
+            parserKind: 'platform-export',
+            parserVersion: PARSER_VERSION,
+            report,
+            content,
+          });
+          if (toWrite.length) {
+            await tx.upsertPlatformRows(toWrite.map((row) => ({ ...row, school, uploadId })));
+          }
+        });
+      } catch (e) {
+        if (e.code === 'OPS_DUPLICATE') {
+          return sendJson(res, 409, { error: '该文件已导入过', uploadId: await store.findUploadBySha(sha256), duplicate: true });
+        }
+        return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
+      }
+
+      platformRowCache.delete(store);
+      return sendJson(res, 200, {
+        ok: true,
+        uploadId,
+        report,
+        warnings: parsed.warnings.slice(0, 200),
+        duplicateData: report.newRows === 0 && report.conflictRows === 0,
+      });
+    }
+
+    // ---------- 人工统计表 ----------
+    // 表格里没有「学校名称」列、上传时也没指定 → 让用户补选,避免落成空白分组
+    if (!parsed.shops.some((shop) => shop.school && shop.school.trim())) {
+      return sendJson(res, 400, {
+        error: '表格里没有「学校名称」列,请在上传时选择或填写学校',
+        code: 'OPS_SCHOOL_REQUIRED',
+      });
+    }
+
+    // 覆盖计数:该区间内已存在的「日期 + 店铺」组合
+    let overwritten = 0;
+    try {
+      const existingDaily = await store.queryRows({ from, to });
+      const knownDaily = new Set(existingDaily.map((r) => `${r.date}|${r.shopKey}`));
+      for (const row of parsed.rows) if (knownDaily.has(`${row.date}|${row.shopKey}`)) overwritten += 1;
+    } catch { /* 计数失败不影响导入 */ }
+
+    const report = {
+      kind: 'manual-ops',
+      rows: parsed.rows.length,
+      shops: parsed.shops.length,
+      overwritten,
+      warningCount: parsed.warnings.length,
+      dates: { from, to },
+      year,
+      schoolOverride: school,
+      parser: { kind: 'manual-ops', version: PARSER_VERSION },
+      warnings: parsed.warnings.slice(0, 500),
+    };
+
+    try {
+      await store.withTransaction(async (tx) => {
+        if (!body.force && await tx.findUploadBySha(sha256)) {
+          const err = new Error('duplicate');
+          err.code = 'OPS_DUPLICATE';
+          throw err;
+        }
+        if (body.force) await tx.deleteRowsBySha(sha256);
+        await tx.insertUpload({
+          id: uploadId,
+          originalName: fileName,
+          sha256,
+          sizeBytes: content.length,
+          uploadedAt,
+          year,
+          parserKind: 'manual-ops',
+          parserVersion: PARSER_VERSION,
+          report,
+          content,
+        });
+        await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId })));
+        await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: uploadId })));
+        await tx.pruneOrphanShops();
+      });
+    } catch (e) {
+      if (e.code === 'OPS_DUPLICATE') {
+        return sendJson(res, 409, { error: '该文件已导入过', uploadId: await store.findUploadBySha(sha256), duplicate: true });
+      }
+      return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
+    }
+
+    platformRowCache.delete(store);
+    sendJson(res, 200, {
+      ok: true,
+      uploadId,
+      report,
+      warnings: parsed.warnings.slice(0, 200),
+    });
+  });
+
+  // ---------- 概览 ----------
+
+  app.get('/api/ops/summary', async ({ res }) => {
+    const health = await store.health();
+    if (!health.ok) {
+      return sendJson(res, 200, {
+        enabled: false,
+        driver: store.kind,
+        reason: health.reason || 'storage-unreachable',
+        detail: health.detail || '',
+      });
+    }
+    try {
+      const data = await store.summary();
+      sendJson(res, 200, { enabled: true, driver: store.kind, ...data });
+    } catch (e) {
+      sendJson(res, 200, {
+        enabled: false,
+        driver: store.kind,
+        reason: 'storage-unreachable',
+        detail: e.message || String(e),
+      });
+    }
+  });
+
+  // ---------- 矩阵查询 ----------
+
+  app.get('/api/ops/fields', async ({ res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const fields = await buildFieldCatalog(store);
+      sendJson(res, 200, {
+        fields: fields.map(({ key, label, type, source }) => ({ key, label, type, source })),
+        defaults: BASE_FIELD_KEYS,
+      });
+    } catch (e) {
+      sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
+    }
+  });
+
+  // ---------- 统一报告生成(经营解读与经营报告共用一份 JSON) ----------
+  async function currentDataVersion() {
+    try {
+      const summary = await store.summary();
+      return `${summary.uploadCount || 0}|${summary.platformRowCount || 0}|${(summary.coverage && summary.coverage.to) || ''}`;
+    } catch {
+      return '';
+    }
+  }
+
+  async function getReportPayload(params, { ai = false, force = false } = {}) {
+    const key = JSON.stringify(params);
+    const version = await currentDataVersion();
+    if (!force && version) {
+      const cached = await store.getReportCache(key, version);
+      if (cached && cached.report && (!ai || cached.source === 'llm')) {
+        return { ...cached.report, cached: true };
+      }
+    }
+
+    const data = await buildDashboard(store, params);
+    if (!data.kpis) return { empty: true };
+
+    const uploads = await store.listUploads().catch(() => []);
+    const base = buildReport(data, {
+      organization: (params.schools && params.schools[0]) || '',
+      sourceFiles: uploads.slice(0, 3).map((u) => u.originalName),
+    });
+    base.ai = { source: 'rules', model: '', generatedAt: new Date().toISOString() };
+
+    let payload = base;
+    let source = 'rules';
+    let aiError = '';
+    const chatCfg = makeChatCfg(ctx);
+    const configured = chatCfg.chatApiKey && chatCfg.chatApiKey !== 'your-api-key-here';
+
+    if (ai && configured) {
+      let aiOk = false;
+      for (let attempt = 0; attempt < 2 && !aiOk; attempt += 1) {
+        try {
+          const text = await chatCompletion({
+            apiBase: chatCfg.chatApiBase,
+            apiKey: chatCfg.chatApiKey,
+            model: chatCfg.chatModel,
+            temperature: 0.3,
+            timeoutMs: 60000,
+            messages: [
+              { role: 'system', content: REPORT_AI_PROMPT },
+              { role: 'user', content: JSON.stringify(insightContext(data)) },
+            ],
+          });
+          const merged = overlayAiReport(base, extractJson(text), { model: chatCfg.chatModel });
+          if (merged) {
+            payload = merged;
+            source = 'llm';
+            aiOk = true;
+            aiError = '';
+          } else {
+            aiError = '模型返回结构不合法,已退回规则版';
+          }
+        } catch (e) {
+          aiError = e.message || String(e);
+          if (attempt === 0) await new Promise((resolve) => setTimeout(resolve, 800));
+        }
+      }
+    } else if (ai && !configured) {
+      aiError = '未配置对话模型,当前为规则版';
+    }
+    if (aiError) payload.aiError = aiError;
+
+    if (version) {
+      try {
+        await store.saveReportCache({
+          cacheKey: key,
+          updatedAt: new Date().toISOString(),
+          dataVersion: version,
+          source,
+          report: payload,
+        });
+      } catch { /* 缓存写失败不影响返回 */ }
+    }
+    return { ...payload, cached: false };
+  }
+
+  // 经营解读 = 报告 JSON 的摘要视图
+  app.post('/api/ops/insights', async ({ req, res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const body = await readBody(req);
+    const params = {
+      from: parseYmd(body.from) || undefined,
+      to: parseYmd(body.to) || undefined,
+      schools: splitList(body.schools),
+      shops: splitList(body.shops),
+    };
+    try {
+      const report = await getReportPayload(params, { ai: true, force: !!body.refresh });
+      if (report.empty || !report.executive_summary) {
+        return sendJson(res, 200, { enabled: true, points: [], note: '所选范围没有数据' });
+      }
+      const findings = report.executive_summary.key_findings || [];
+      const points = findings.slice(0, 5).map((f) => f.finding);
+      const alerts = (report.alerts || []).slice(0, 3).map((a) => ({ severity: a.severity, title: a.title }));
+      sendJson(res, 200, {
+        enabled: true,
+        points,
+        alerts,
+        source: (report.ai && report.ai.source) || 'rules',
+        model: (report.ai && report.ai.model) || '',
+        generatedAt: (report.ai && report.ai.generatedAt) || new Date().toISOString(),
+        aiError: report.aiError || '',
+        range: { from: report.meta.period_start, to: report.meta.period_end },
+        cached: !!report.cached,
+      });
+    } catch (e) {
+      sendJson(res, 502, { enabled: true, error: '生成失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/report', async ({ res, query }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const params = {
+      from: parseYmd(query.from) || undefined,
+      to: parseYmd(query.to) || undefined,
+      schools: splitList(query.schools),
+      shops: splitList(query.shops),
+    };
+    try {
+      const report = await getReportPayload(params, { ai: query.ai === '1', force: query.refresh === '1' });
+      if (report.empty) return sendJson(res, 200, { empty: true });
+      sendJson(res, 200, { report });
+    } catch (e) {
+      sendJson(res, 503, { error: '报告生成失败', detail: e.message || String(e) });
+    }
+  });
+
+  // ---------- 报告中心(保存 / 列表 / 查看 / 删除) ----------
+
+  app.post('/api/ops/reports', async ({ req, res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const body = await readBody(req);
+    const params = {
+      from: parseYmd(body.from) || undefined,
+      to: parseYmd(body.to) || undefined,
+      schools: splitList(body.schools),
+      shops: splitList(body.shops),
+    };
+    try {
+      // 前端会把它当前看到的 Report JSON 一起提交,保证「保存的就是所见」
+      let report = body.report;
+      if (!report || !report.meta || !Array.isArray(report.kpis)) {
+        report = await getReportPayload(params, { ai: false });
+      }
+      if (!report || report.empty) return sendJson(res, 400, { error: '所选范围没有数据,无法保存' });
+      const id = `rpt_${newUploadId()}`;
+      const record = {
+        id,
+        createdAt: new Date().toISOString(),
+        periodFrom: String(report.meta.period_start || '').replace(/-/g, ''),
+        periodTo: String(report.meta.period_end || '').replace(/-/g, ''),
+        schools: params.schools,
+        shops: params.shops,
+        source: (report.ai && report.ai.source) || 'rules',
+        model: (report.ai && report.ai.model) || '',
+        title: report.meta.title || '经营分析报告',
+        report,
+      };
+      await store.insertReport(record);
+      sendJson(res, 200, { ok: true, id, createdAt: record.createdAt, source: record.source });
+    } catch (e) {
+      sendJson(res, 500, { error: '保存失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/reports', async ({ res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const reports = await store.listReports();
+      // 把保存时记录的店铺 key 映射成名称,历史记录才看得懂范围
+      let shopNameByKey = new Map();
+      try {
+        const summary = await store.summary();
+        shopNameByKey = new Map((summary.shops || []).map((s) => [String(s.key || s.shopId || ''), s.shopName || '']));
+      } catch { /* 映射失败时保留 key */ }
+      sendJson(res, 200, {
+        reports: reports.map((item) => ({
+          ...item,
+          schoolNames: item.schools || [],
+          shopNames: (item.shops || []).map((key) => shopNameByKey.get(String(key)) || String(key)),
+        })),
+      });
+    } catch (e) {
+      sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/reports/:id', async ({ res, params }) => {
+    if (!/^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(params.id)) return sendJson(res, 400, { error: '非法的报告 ID' });
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const report = await store.getReport(params.id);
+      if (!report) return sendJson(res, 404, { error: '报告不存在' });
+      sendJson(res, 200, { report });
+    } catch (e) {
+      sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.del('/api/ops/reports/:id', async ({ res, params }) => {
+    if (!/^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(params.id)) return sendJson(res, 400, { error: '非法的报告 ID' });
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const removed = await store.deleteReport(params.id);
+      sendJson(res, 200, { ok: true, removed });
+    } catch (e) {
+      sendJson(res, 500, { error: '删除失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/dashboard', async ({ res, query }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const from = parseYmd(query.from);
+    const to = parseYmd(query.to);
+    const schools = splitList(query.schools);
+    const shops = splitList(query.shops);
+    try {
+      const data = await buildDashboard(store, { from: from || undefined, to: to || undefined, schools, shops });
+      sendJson(res, 200, data);
+    } catch (e) {
+      sendJson(res, 503, { error: '聚合失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/rows', async ({ res, query }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const from = parseYmd(query.from);
+    const to = parseYmd(query.to);
+    const schools = splitList(query.schools);
+    const shops = splitList(query.shops);
+    let rows;
+    try {
+      rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
+    } catch (e) {
+      return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
+    }
+    const catalog = await buildFieldCatalog(store);
+    const wantedFields = parseFieldKeys(query.fields);
+    let fields = wantedFields.length
+      ? wantedFields.map((key) => catalog.find((f) => f.key === key)).filter(Boolean)
+      : catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+    if (!fields.length) fields = catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+
+    const dates = [...new Set(rows.map((r) => r.date))].sort();
+    sendJson(res, 200, {
+      from: from || (dates[0] || null),
+      to: to || (dates[dates.length - 1] || null),
+      dates,
+      fields: fields.map(({ key, label, type }) => ({ key, label, type })),
+      groups: buildMatrix(rows, dates, fields),
+    });
+  });
+
+  // ---------- 导入记录 ----------
+
+  app.get('/api/ops/uploads', async ({ res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const uploads = await store.listUploads();
+      sendJson(res, 200, { uploads });
+    } catch (e) {
+      sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
+    }
+  });
+
+  // ---------- 原件下载 ----------
+
+  app.get('/api/ops/uploads/:id/file', async ({ res, params }) => {
+    if (!UPLOAD_ID_RE.test(params.id)) return sendJson(res, 400, { error: '非法的上传 ID' });
+    if (!(await requireHealthy(store, res))) return;
+    const record = await store.getContent(params.id);
+    if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
+    const name = sanitizeName(record.originalName);
+    res.writeHead(200, {
+      'Content-Type': XLSX_MIME,
+      'Content-Length': record.content.length,
+      'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,
+    });
+    res.end(record.content);
+  });
+
+  // ---------- 重新解析 ----------
+
+  app.post('/api/ops/uploads/:id/reparse', async ({ res, params }) => {
+    if (!UPLOAD_ID_RE.test(params.id)) return sendJson(res, 400, { error: '非法的上传 ID' });
+    if (!(await requireHealthy(store, res))) return;
+    const record = await store.getContent(params.id);
+    if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
+    const upload = await store.getUpload(params.id);
+    const year = (upload && upload.year) || new Date().getFullYear();
+    const school = (upload && upload.report && upload.report.schoolOverride) || '';
+    let parsed;
+    try {
+      parsed = opsImport.parseAny(record.content, { year, school });
+    } catch (e) {
+      return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
+    }
+    const from = parsed.dates[0] || '';
+    const to = parsed.dates[parsed.dates.length - 1] || '';
+
+    if (parsed.kind === 'platform-export') {
+      const report = {
+        kind: 'platform-export',
+        rows: parsed.rows.length,
+        shops: parsed.shops.length,
+        newRows: parsed.rows.length,
+        identicalRows: 0,
+        conflictRows: 0,
+        writtenRows: parsed.rows.length,
+        warningCount: parsed.warnings.length,
+        dates: { from, to },
+        year,
+        schoolOverride: school,
+        parser: { kind: 'platform-export', version: PARSER_VERSION },
+        warnings: parsed.warnings.slice(0, 500),
+        reparsedAt: new Date().toISOString(),
+      };
+      try {
+        await store.withTransaction(async (tx) => {
+          await tx.deletePlatformRowsByUpload(params.id);
+          await tx.upsertPlatformRows(parsed.rows.map((row) => ({ ...row, school, uploadId: params.id })));
+          await tx.updateReport(params.id, report);
+        });
+      } catch (e) {
+        return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
+      }
+      return sendJson(res, 200, { ok: true, uploadId: params.id, report });
+    }
+
+    const report = {
+      kind: 'manual-ops',
+      rows: parsed.rows.length,
+      shops: parsed.shops.length,
+      overwritten: 0,
+      warningCount: parsed.warnings.length,
+      dates: { from, to },
+      year,
+      schoolOverride: school,
+      parser: { kind: 'manual-ops', version: PARSER_VERSION },
+      warnings: parsed.warnings.slice(0, 500),
+      reparsedAt: new Date().toISOString(),
+    };
+    try {
+      await store.withTransaction(async (tx) => {
+        await tx.deleteRowsByUpload(params.id);
+        await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId: params.id })));
+        await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: params.id })));
+        await tx.pruneOrphanShops();
+        await tx.updateReport(params.id, report);
+      });
+    } catch (e) {
+      return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
+    }
+    sendJson(res, 200, { ok: true, uploadId: params.id, report });
+  });
+
+  // ---------- 导出 ----------
+
+  app.get('/api/ops/export', async ({ res, query }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const from = parseYmd(query.from);
+    const to = parseYmd(query.to);
+    const schools = splitList(query.schools);
+    const shops = splitList(query.shops);
+    let rows;
+    try {
+      rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
+    } catch (e) {
+      return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
+    }
+
+    const catalog = await buildFieldCatalog(store);
+    const wantedFields = parseFieldKeys(query.fields);
+    let fields = wantedFields.length
+      ? wantedFields.map((key) => catalog.find((f) => f.key === key)).filter(Boolean)
+      : catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+    if (!fields.length) fields = catalog.filter((f) => BASE_FIELD_KEYS.includes(f.key));
+
+    const sorted = rows.slice().sort((a, b) => {
+      const school = String(a.school || '').localeCompare(String(b.school || ''), 'zh-Hans-CN');
+      if (school !== 0) return school;
+      const shop = String(a.shopName || '').localeCompare(String(b.shopName || ''), 'zh-Hans-CN');
+      if (shop !== 0) return shop;
+      return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
+    });
+
+    const lines = [];
+    lines.push(['学校', '店铺ID', '店铺名称', '日期', ...fields.map((f) => f.label)].map(csvCell).join(','));
+    for (const row of sorted) {
+      lines.push([
+        row.school || '',
+        row.shopId || '',
+        row.shopName || '',
+        row.date,
+        ...fields.map((field) => {
+          const value = fieldValue(row, field);
+          return value == null ? '' : value;
+        }),
+      ].map(csvCell).join(','));
+    }
+
+    const csv = '\ufeff' + lines.join('\r\n');
+    res.writeHead(200, {
+      'Content-Type': 'text/csv; charset=utf-8',
+      'Content-Disposition': `attachment; filename="ops-${from || 'all'}-${to || 'all'}.csv"`,
+    });
+    res.end(csv);
+  });
+
+}
+
+module.exports = { register };

+ 133 - 0
server/routes/schools.js

@@ -0,0 +1,133 @@
+'use strict';
+
+// 校区地图:学校列表(JSON 种子 + 运行时),附带运营数据关联统计。
+// 本期只有只读接口;新建/编辑/启停在下一增量实现。
+
+const fs = require('fs');
+const path = require('path');
+const { sendJson } = require('../util.js');
+const { loadSchools } = require('../../schools-store.js');
+const { createOpsStore } = require('../../ops-store.js');
+
+// 导入数据里出现、但还没登记的校区:用本地高校坐标库自动定位(找不到就标未定位)
+let uniCache = null;
+function loadUniversities() {
+  if (uniCache) return uniCache;
+  try {
+    uniCache = JSON.parse(fs.readFileSync(path.join(__dirname, '../../public/vendor/cn-universities.json'), 'utf8'));
+  } catch {
+    uniCache = {};
+  }
+  return uniCache;
+}
+
+function normalizeSchoolName(name) {
+  return String(name || '')
+    .replace(/\s+/g, '')
+    .replace(/[((].*?[))]/g, '')
+    .replace(/(校区|分校)$/, '');
+}
+
+function matchUniversity(name) {
+  const uni = loadUniversities();
+  const norm = normalizeSchoolName(name);
+  if (!norm) return null;
+  if (uni[norm]) return { lng: uni[norm].lng, lat: uni[norm].lat, province: uni[norm].province, approx: false };
+  let bestKey = '';
+  for (const key of Object.keys(uni)) {
+    if (key.length < 4) continue;
+    if (norm.startsWith(key) && key.length > bestKey.length) bestKey = key;
+  }
+  if (!bestKey) return null;
+  return { lng: uni[bestKey].lng, lat: uni[bestKey].lat, province: uni[bestKey].province, approx: true, base: bestKey };
+}
+
+function namesOf(school) {
+  return new Set([school.name, ...(school.aliases || [])].map((n) => String(n).trim()).filter(Boolean));
+}
+
+function register(app, ctx) {
+  const opsStore = createOpsStore(ctx.config);
+
+  app.get('/api/schools', async ({ res }) => {
+    const schools = loadSchools();
+
+    // 关联运营数据:按学校名(含别名)匹配 ops_shop.school 与 ops_row.school
+    let shops = [];
+    let rows = [];
+    let platformRows = [];
+    let opsAvailable = false;
+    try {
+      const summary = await opsStore.summary();
+      shops = summary.shops || [];
+      rows = await opsStore.queryRows({});
+      platformRows = await opsStore.queryPlatformRows({});
+      opsAvailable = true;
+    } catch { /* ops 库不可用:只影响关联字段 */ }
+
+    // 运营数据里出现、注册表里没有的学校:自动补进地图列表
+    const knownNames = new Set();
+    for (const school of schools) for (const n of namesOf(school)) knownNames.add(n);
+    const discovered = new Set();
+    for (const item of [...shops, ...platformRows]) {
+      const name = String(item.school || '').trim();
+      if (name && !knownNames.has(name)) discovered.add(name);
+    }
+    const now = new Date().toISOString();
+    for (const name of discovered) {
+      const hit = matchUniversity(name);
+      schools.push({
+        id: 'ops_' + Buffer.from(name).toString('hex').slice(0, 12),
+        name,
+        aliases: [],
+        province: (hit && hit.province) || '',
+        city: '',
+        county: '',
+        lng: hit ? hit.lng : null,
+        lat: hit ? hit.lat : null,
+        source: 'ops',
+        seed: false,
+        discovered: true,
+        approx: !!(hit && hit.approx),
+        enabled: !!hit,
+        created_at: now,
+        updated_at: now,
+      });
+    }
+
+    const payload = schools.map((school) => {
+      const names = namesOf(school);
+      // 门店口径:人工表的 shop_key 与平台明细的 shopId 取并集
+      const shopKeys = new Set();
+      for (const shop of shops) {
+        if (names.has(String(shop.school || ''))) shopKeys.add(String(shop.key || shop.shopId || ''));
+      }
+      for (const row of platformRows) {
+        if (names.has(String(row.school || ''))) shopKeys.add(String(row.shopId || ''));
+      }
+      shopKeys.delete('');
+      const shopCount = shopKeys.size;
+      let lastDate = '';
+      for (const row of rows) {
+        if (!names.has(String(row.school || ''))) continue;
+        if (!lastDate || row.date > lastDate) lastDate = row.date;
+      }
+      for (const row of platformRows) {
+        if (!names.has(String(row.school || ''))) continue;
+        if (!lastDate || row.date > lastDate) lastDate = row.date;
+      }
+      return {
+        ...school,
+        ops: opsAvailable ? { shopCount, lastDate: lastDate || null } : null,
+      };
+    });
+
+    sendJson(res, 200, {
+      schools: payload,
+      opsAvailable,
+      enabledCount: payload.filter((s) => s.enabled && s.lng != null).length,
+    });
+  });
+}
+
+module.exports = { register };

+ 102 - 0
server/routes/settings.js

@@ -193,6 +193,108 @@ function register(app, ctx) {
       sendJson(res, 502, { error: e.message });
     }
   });
+  // ---------- 云端数据库(PostgreSQL)配置 ----------
+
+  function pgInstalled() {
+    try { require.resolve('pg'); return true; } catch { return false; }
+  }
+
+  function storageFormFromBody(body) {
+    const current = (ctx.config.storage && ctx.config.storage.postgres) || {};
+    const driver = body.driver === 'postgres' ? 'postgres' : 'sqlite';
+    const pw = body.pgPassword;
+    return {
+      driver,
+      sqlite: {
+        file: String(body.sqliteFile
+          || (ctx.config.storage && ctx.config.storage.sqlite && ctx.config.storage.sqlite.file)
+          || 'data/ops.db'),
+      },
+      postgres: {
+        // 未提交的字段回落到已保存配置(测试连接时可直接复用)
+        host: String(body.pgHost !== undefined ? body.pgHost : (current.host || '')).trim(),
+        port: Number(body.pgPort) || current.port || 5432,
+        database: String(body.pgDatabase !== undefined ? body.pgDatabase : (current.database || '')).trim(),
+        user: String(body.pgUser !== undefined ? body.pgUser : (current.user || '')).trim(),
+        // 留空 = 沿用已保存密码(否则空串会覆盖密码,触发 SASL 'password must be a string')
+        password: (pw !== undefined && pw !== null && String(pw).trim() !== '' && !String(pw).includes('••'))
+          ? String(pw)
+          : (current.password || ''),
+        ssl: body.pgSsl !== undefined ? !!body.pgSsl : !!current.ssl,
+        schema: String(body.pgSchema || current.schema || 'public'),
+        max: current.max || 5,
+        connectTimeoutMs: 10000,
+      },
+    };
+  }
+
+  app.get('/api/settings/storage', async ({ res }) => {
+    const st = ctx.config.storage || {};
+    const pg = st.postgres || {};
+    sendJson(res, 200, {
+      driver: st.driver || 'sqlite',
+      sqliteFile: (st.sqlite && st.sqlite.file) || 'data/ops.db',
+      pgInstalled: pgInstalled(),
+      postgres: {
+        host: pg.host || '',
+        port: pg.port || 5432,
+        database: pg.database || '',
+        user: pg.user || '',
+        passwordSet: !!pg.password,
+        ssl: !!pg.ssl,
+        schema: pg.schema || 'public',
+      },
+    });
+  });
+
+  app.post('/api/settings/storage/test', async ({ req, res }) => {
+    const body = await readBody(req);
+    if (!pgInstalled()) return sendJson(res, 200, { ok: false, error: '未安装 pg 驱动(npm install pg)' });
+    const form = storageFormFromBody(body).postgres;
+    if (!form.host || !form.database || !form.user) {
+      return sendJson(res, 200, { ok: false, error: '请填写主机、数据库名和用户名' });
+    }
+    const { Client } = require('pg');
+    const client = new Client({
+      host: form.host,
+      port: form.port,
+      database: form.database,
+      user: form.user,
+      password: form.password,
+      ssl: form.ssl ? { rejectUnauthorized: false } : false,
+      connectionTimeoutMillis: 8000,
+    });
+    try {
+      await client.connect();
+      const r = await client.query('select version() as v, current_database() as db');
+      sendJson(res, 200, {
+        ok: true,
+        version: String(r.rows[0].v).split(' ').slice(0, 2).join(' '),
+        database: r.rows[0].db,
+      });
+    } catch (e) {
+      sendJson(res, 200, { ok: false, error: e.message || String(e) });
+    } finally {
+      await client.end().catch(() => {});
+    }
+  });
+
+  app.post('/api/settings/storage', async ({ req, res }) => {
+    const body = await readBody(req);
+    const next = storageFormFromBody(body);
+    if (next.driver === 'postgres' && (!next.postgres.host || !next.postgres.database || !next.postgres.user)) {
+      return sendJson(res, 400, { error: '启用云端数据库需要填写主机、数据库名和用户名' });
+    }
+    const previous = (ctx.config.storage && ctx.config.storage.driver) || 'sqlite';
+    ctx.config.storage = { ...(ctx.config.storage || {}), ...next };
+    ctx.saveConfig();
+    ctx.logs.pushSystemLog(
+      `存储驱动已配置为 ${next.driver}${next.driver === 'postgres' ? `(${next.postgres.host}:${next.postgres.port}/${next.postgres.database})` : ''}`,
+      'success',
+    );
+    sendJson(res, 200, { ok: true, restartRequired: previous !== next.driver });
+  });
+
 }
 
 module.exports = { register };

+ 8 - 0
server/routes/static.js

@@ -16,6 +16,14 @@ const STATIC_MAP = {
   '/analysis-templates.js': ['analysis-templates.js', 'application/javascript; charset=utf-8'],
   '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
   '/lake.js': ['lake.js', 'application/javascript; charset=utf-8'],
+  '/ops.js': ['ops.js', 'application/javascript; charset=utf-8'],
+  '/schools.js': ['schools.js', 'application/javascript; charset=utf-8'],
+  '/multi-select.js': ['multi-select.js', 'application/javascript; charset=utf-8'],
+  '/cockpit.js': ['cockpit.js', 'application/javascript; charset=utf-8'],
+  '/report.js': ['report.js', 'application/javascript; charset=utf-8'],
+  '/vendor/china-map.json': ['vendor/china-map.json', 'application/json; charset=utf-8'],
+  '/vendor/cn-universities.json': ['vendor/cn-universities.json', 'application/json; charset=utf-8'],
+  '/vendor/cn-cities.json': ['vendor/cn-cities.json', 'application/json; charset=utf-8'],
   '/ask.js': ['ask.js', 'application/javascript; charset=utf-8'],
   '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],
   '/style.css': ['style.css', 'text/css; charset=utf-8'],

+ 21 - 0
server/schools.seed.json

@@ -0,0 +1,21 @@
+{
+  "version": 1,
+  "note": "内置学校(随版本发布)。用户新增/修改写入 data/schools.json,读取时按 name 合并,运行时覆盖种子。",
+  "schools": [
+    {
+      "id": "sch_tonglu_001",
+      "name": "浙江工商大学桐庐校区",
+      "aliases": ["浙江工商桐庐校区", "浙江工商大学杭州商学院", "杭州商学院桐庐校区"],
+      "province": "浙江省",
+      "city": "杭州市",
+      "county": "桐庐县",
+      "lng": 119.744166,
+      "lat": 29.830417,
+      "source": "seed",
+      "seed": true,
+      "enabled": true,
+      "created_at": "2026-10-20T00:00:00.000Z",
+      "updated_at": "2026-10-20T00:00:00.000Z"
+    }
+  ]
+}

+ 238 - 0
server/services/xlsx.js

@@ -0,0 +1,238 @@
+'use strict';
+
+/**
+ * 最小 xlsx 读取器(零依赖)
+ * ----------------------------------------------------------------------------
+ * xlsx 是 ZIP 容器 + XML。这里只做一件事:把第一个工作表的单元格读成二维数组,
+ * 供解析器使用。够用即可,不做通用电子表格库。
+ *
+ * 支持:共享字符串(t="s")、内联字符串(inlineStr)、缓存值(t="str")、数字、布尔。
+ * 不支持:.xls、公式计算(读缓存值)、样式/颜色、图表、合并单元格语义
+ *        (合并区只取左上角,其余为空)、多工作表遍历(只读第一个)。
+ */
+
+const zlib = require('zlib');
+
+const EOCD_SIG = 0x06054b50;
+const CEN_SIG = 0x02014b50;
+const LOC_SIG = 0x04034b50;
+
+function fail(message, cause) {
+  const err = new Error(message);
+  err.code = 'XLSX_PARSE_FAILED';
+  if (cause) err.cause = cause;
+  return err;
+}
+
+// ---------- ZIP ----------
+
+/** 从尾部找中央目录结束记录(EOCD 可能带注释,最多回退 65557 字节) */
+function findEocd(buf) {
+  const stop = Math.max(0, buf.length - 65557);
+  for (let i = buf.length - 22; i >= stop; i--) {
+    if (buf.readUInt32LE(i) === EOCD_SIG) return i;
+  }
+  return -1;
+}
+
+function readCentralDirectory(buf) {
+  const eocd = findEocd(buf);
+  if (eocd < 0) throw fail('不是有效的 zip/xlsx 文件');
+  const count = buf.readUInt16LE(eocd + 10);
+  const cdOffset = buf.readUInt32LE(eocd + 16);
+  if (cdOffset <= 0 || cdOffset >= buf.length) throw fail('zip 中央目录偏移非法');
+  const entries = new Map();
+  let p = cdOffset;
+  for (let i = 0; i < count; i++) {
+    if (p + 46 > buf.length) throw fail('zip 中央目录被截断');
+    if (buf.readUInt32LE(p) !== CEN_SIG) throw fail('zip 中央目录签名不符');
+    const method = buf.readUInt16LE(p + 10);
+    const compSize = buf.readUInt32LE(p + 20);
+    const nameLen = buf.readUInt16LE(p + 28);
+    const extraLen = buf.readUInt16LE(p + 30);
+    const commentLen = buf.readUInt16LE(p + 32);
+    const localOffset = buf.readUInt32LE(p + 42);
+    const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
+    entries.set(name, { method, compSize, localOffset });
+    p += 46 + nameLen + extraLen + commentLen;
+  }
+  return entries;
+}
+
+function readEntry(buf, entries, name) {
+  const entry = entries.get(name);
+  if (!entry) return null;
+  const p = entry.localOffset;
+  if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) throw fail('zip 本地文件头损坏');
+  // 本地头的 name/extra 长度以本地头为准(中央目录里可能带额外字段)
+  const nameLen = buf.readUInt16LE(p + 26);
+  const extraLen = buf.readUInt16LE(p + 28);
+  const start = p + 30 + nameLen + extraLen;
+  const end = start + entry.compSize;
+  if (end > buf.length) throw fail('zip 条目被截断');
+  const raw = buf.subarray(start, end);
+  if (entry.method === 0) return raw;
+  if (entry.method === 8) {
+    try {
+      return zlib.inflateRawSync(raw);
+    } catch (e) {
+      throw fail('zip 解压失败', e);
+    }
+  }
+  throw fail(`不支持的 zip 压缩方式:${entry.method}`);
+}
+
+// ---------- XML ----------
+
+function decodeXml(text) {
+  return String(text).replace(/&(#x?[0-9a-fA-F]+|lt|gt|amp|quot|apos);/g, (whole, ent) => {
+    switch (ent) {
+      case 'lt': return '<';
+      case 'gt': return '>';
+      case 'amp': return '&';
+      case 'quot': return '"';
+      case 'apos': return "'";
+      default: break;
+    }
+    if (ent[0] === '#') {
+      const code = ent[1] === 'x' || ent[1] === 'X'
+        ? parseInt(ent.slice(2), 16)
+        : parseInt(ent.slice(1), 10);
+      return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
+    }
+    return whole;
+  });
+}
+
+function attrOf(attrs, name) {
+  const m = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs || '');
+  return m ? m[1] : '';
+}
+
+function parseSharedStrings(xml) {
+  const out = [];
+  if (!xml) return out;
+  const siRe = /<si\b[^>]*?(?:\/>|>([\s\S]*?)<\/si>)/g;
+  let m;
+  while ((m = siRe.exec(xml))) {
+    const inner = m[1] || '';
+    let text = '';
+    const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g;
+    let t;
+    while ((t = tRe.exec(inner))) text += decodeXml(t[1]);
+    out.push(text);
+  }
+  return out;
+}
+
+function columnIndex(ref) {
+  const m = /^([A-Za-z]+)/.exec(ref || '');
+  if (!m) return -1;
+  let n = 0;
+  for (const ch of m[1].toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
+  return n - 1;
+}
+
+function parseCell(attrs, inner, shared) {
+  const type = attrOf(attrs, 't');
+  if (type === 'inlineStr') {
+    let text = '';
+    const tRe = /<t\b[^>]*>([\s\S]*?)<\/t>/g;
+    let t;
+    while ((t = tRe.exec(inner))) text += decodeXml(t[1]);
+    return text === '' ? null : text;
+  }
+  const v = /<v\b[^>]*>([\s\S]*?)<\/v>/.exec(inner);
+  if (!v) return null;
+  const raw = decodeXml(v[1]);
+  if (type === 's') {
+    const idx = Number(raw);
+    return Number.isInteger(idx) && idx >= 0 && idx < shared.length ? shared[idx] : null;
+  }
+  if (type === 'str') return raw === '' ? null : raw;
+  if (type === 'b') return raw === '1';
+  if (type === 'e') return null;
+  if (raw === '') return null;
+  const num = Number(raw);
+  return Number.isFinite(num) ? num : raw;
+}
+
+function parseSheet(xml, shared) {
+  const rows = [];
+  const rowRe = /<row\b([^>]*?)(?:\/>|>([\s\S]*?)<\/row>)/g;
+  let m;
+  while ((m = rowRe.exec(xml))) {
+    const rAttr = attrOf(m[1], 'r');
+    const rowIdx = rAttr ? Number(rAttr) - 1 : rows.length;
+    const cells = [];
+    const cellRe = /<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g;
+    let c;
+    while ((c = cellRe.exec(m[2] || ''))) {
+      const ci = columnIndex(attrOf(c[1], 'r'));
+      if (ci < 0) continue;
+      cells[ci] = parseCell(c[1], c[2] || '', shared);
+    }
+    for (let i = 0; i < cells.length; i++) if (cells[i] === undefined) cells[i] = null;
+    rows[rowIdx] = cells;
+  }
+  const out = [];
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i] || [];
+    for (let j = 0; j < row.length; j++) if (row[j] === undefined) row[j] = null;
+    out.push(row);
+  }
+  return out;
+}
+
+/** 工作表路径:优先读 workbook.xml + rels,失败则回退到第一个 sheetN.xml */
+function resolveFirstSheet(buf, entries) {
+  const workbook = readEntry(buf, entries, 'xl/workbook.xml');
+  const rels = readEntry(buf, entries, 'xl/_rels/workbook.xml.rels');
+  if (workbook && rels) {
+    const sheetTag = /<sheet\b([^>]*)\/?>/.exec(workbook.toString('utf8'));
+    const rid = sheetTag ? attrOf(sheetTag[1], 'r:id') : '';
+    if (rid) {
+      const relRe = /<Relationship\b([^>]*)\/?>/g;
+      let m;
+      const relsText = rels.toString('utf8');
+      while ((m = relRe.exec(relsText))) {
+        if (attrOf(m[1], 'Id') === rid) {
+          let target = attrOf(m[1], 'Target');
+          if (!target) break;
+          target = target.replace(/^\/?xl\//, '').replace(/^\//, '');
+          return { path: 'xl/' + target, name: '' };
+        }
+      }
+    }
+  }
+  const names = [...entries.keys()]
+    .filter((n) => /^xl\/worksheets\/sheet\d+\.xml$/.test(n))
+    .sort((a, b) => Number(/(\d+)/.exec(a)[1]) - Number(/(\d+)/.exec(b)[1]));
+  if (!names.length) throw fail('xlsx 中没有工作表');
+  return { path: names[0], name: '' };
+}
+
+/**
+ * 读取第一个工作表
+ * @param {Buffer} buffer
+ * @returns {{ sheets: Array<{ name: string, rows: Array<Array<string|number|boolean|null>> }> }}
+ */
+function readXlsx(buffer) {
+  if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw fail('文件内容为空或不是 xlsx');
+  const entries = readCentralDirectory(buffer);
+  const sheetRef = resolveFirstSheet(buffer, entries);
+  const sheetXml = readEntry(buffer, entries, sheetRef.path);
+  if (!sheetXml) throw fail('未找到工作表数据');
+  const sharedXml = readEntry(buffer, entries, 'xl/sharedStrings.xml');
+  const shared = parseSharedStrings(sharedXml ? sharedXml.toString('utf8') : '');
+  let rows;
+  try {
+    rows = parseSheet(sheetXml.toString('utf8'), shared);
+  } catch (e) {
+    if (e.code === 'XLSX_PARSE_FAILED') throw e;
+    throw fail('工作表 XML 解析失败', e);
+  }
+  return { sheets: [{ name: sheetRef.name || 'Sheet1', rows }] };
+}
+
+module.exports = { readXlsx };

+ 463 - 0
server/stores/ops-pg.js

@@ -0,0 +1,463 @@
+'use strict';
+
+/**
+ * ops 存储 · PostgreSQL 驱动
+ * ----------------------------------------------------------------------------
+ * 与 SQLite 驱动同接口。`pg` 为可选依赖(本期不安装):未安装时 health() 返回明确原因,
+ * 只有「店铺运营」功能不可用,服务与其它功能不受影响。
+ *
+ * 上线切换步骤见设计文档 §5.6:改 config.storage.driver = "postgres" + 填连接串 + npm i pg。
+ */
+
+const SCHEMA_VERSION = '1';
+
+const DDL = [
+  `CREATE TABLE IF NOT EXISTS ops_meta (
+     key text PRIMARY KEY,
+     value text NOT NULL
+   )`,
+  `CREATE TABLE IF NOT EXISTS ops_upload (
+     id text PRIMARY KEY,
+     original_name text NOT NULL,
+     sha256 text NOT NULL,
+     size_bytes integer NOT NULL,
+     uploaded_at timestamptz NOT NULL,
+     year integer NOT NULL,
+     parser_kind text NOT NULL,
+     parser_version integer NOT NULL,
+     report jsonb NOT NULL DEFAULT '{}'::jsonb,
+     content bytea NOT NULL
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_upload_sha256_idx ON ops_upload (sha256, uploaded_at DESC)`,
+  `CREATE TABLE IF NOT EXISTS ops_shop (
+     key text PRIMARY KEY,
+     shop_id text,
+     shop_name text NOT NULL,
+     school text NOT NULL,
+     status text,
+     last_upload_id text REFERENCES ops_upload(id) ON DELETE SET NULL,
+     first_seen timestamptz NOT NULL,
+     last_seen timestamptz NOT NULL
+   )`,
+  `CREATE TABLE IF NOT EXISTS ops_row (
+     date char(8) NOT NULL,
+     shop_key text NOT NULL,
+     upload_id text NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
+     shop_id text,
+     shop_name text NOT NULL,
+     school text NOT NULL,
+     status text,
+     order_cnt integer,
+     revenue numeric(14,2),
+     exposure integer,
+     click_rate numeric(8,6),
+     raw jsonb NOT NULL DEFAULT '{}'::jsonb,
+     quality text[] NOT NULL DEFAULT '{}',
+     updated_at timestamptz NOT NULL,
+     PRIMARY KEY (date, shop_key)
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date)`,
+  `CREATE TABLE IF NOT EXISTS ops_platform_row (
+     date char(8) NOT NULL,
+     shop_id text NOT NULL,
+     upload_id text NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
+     school text NOT NULL DEFAULT '',
+     shop_name text NOT NULL DEFAULT '',
+     data jsonb NOT NULL DEFAULT '{}'::jsonb,
+     updated_at timestamptz NOT NULL,
+     PRIMARY KEY (date, shop_id)
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_platform_row_date_idx ON ops_platform_row (date)`,
+  `CREATE TABLE IF NOT EXISTS ops_report (
+     id text PRIMARY KEY,
+     created_at timestamptz NOT NULL,
+     period_from char(8) NOT NULL,
+     period_to char(8) NOT NULL,
+     schools jsonb NOT NULL DEFAULT '[]'::jsonb,
+     shops jsonb NOT NULL DEFAULT '[]'::jsonb,
+     source text NOT NULL DEFAULT 'rules',
+     model text NOT NULL DEFAULT '',
+     title text NOT NULL DEFAULT '',
+     report jsonb NOT NULL
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
+  `CREATE TABLE IF NOT EXISTS ops_report_cache (
+     cache_key text PRIMARY KEY,
+     updated_at timestamptz NOT NULL,
+     data_version text NOT NULL,
+     source text NOT NULL DEFAULT 'rules',
+     report jsonb NOT NULL
+   )`,
+];
+
+function rowToApi(row) {
+  if (!row) return null;
+  return {
+    date: String(row.date || '').trim(),
+    shopKey: row.shop_key,
+    shopId: row.shop_id,
+    shopName: row.shop_name,
+    school: row.school,
+    status: row.status,
+    order_cnt: row.order_cnt == null ? null : Number(row.order_cnt),
+    revenue: row.revenue == null ? null : Number(row.revenue),
+    exposure: row.exposure == null ? null : Number(row.exposure),
+    click_rate: row.click_rate == null ? null : Number(row.click_rate),
+    raw: row.raw || {},
+    quality: row.quality || [],
+    uploadId: row.upload_id,
+    updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
+  };
+}
+
+function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public', connectTimeoutMs = 10000 }) {
+  let pool = null;
+  let initPromise = null;
+
+  function getPool() {
+    if (pool) return pool;
+    let pg;
+    try {
+      pg = require('pg');
+    } catch {
+      const err = new Error('未安装 pg(npm i pg)');
+      err.reason = 'pg-not-installed';
+      throw err;
+    }
+    pool = new pg.Pool({
+      connectionString,
+      max: Number(max) || 5,
+      ssl: ssl ? { rejectUnauthorized: false } : false,
+      connectionTimeoutMillis: Number(connectTimeoutMs) || 10000,
+      options: `-c search_path=${schema || 'public'}`,
+    });
+    return pool;
+  }
+
+  async function init() {
+    if (!initPromise) {
+      initPromise = (async () => {
+        const client = await getPool().connect();
+        try {
+          for (const sql of DDL) await client.query(sql);
+          await client.query(
+            'INSERT INTO ops_meta (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value',
+            ['schema_version', SCHEMA_VERSION],
+          );
+        } finally {
+          client.release();
+        }
+        return true;
+      })().catch((e) => {
+        initPromise = null;
+        throw e;
+      });
+    }
+    return initPromise;
+  }
+
+  async function health() {
+    try {
+      await getPool().query('SELECT 1 AS ok');
+      return { ok: true, kind: 'postgres', detail: (schema || 'public') };
+    } catch (e) {
+      return { ok: false, kind: 'postgres', detail: e.message || String(e) };
+    }
+  }
+
+  async function withTransaction(fn) {
+    const client = await getPool().connect();
+    try {
+      await client.query('BEGIN');
+      const result = await fn(makeTx(client));
+      await client.query('COMMIT');
+      return result;
+    } catch (e) {
+      try { await client.query('ROLLBACK'); } catch { /* 已结束 */ }
+      throw e;
+    } finally {
+      client.release();
+    }
+  }
+
+  function makeTx(client) {
+    return {
+      async insertUpload(upload) {
+        await client.query(
+          `INSERT INTO ops_upload (id, original_name, sha256, size_bytes, uploaded_at, year,
+             parser_kind, parser_version, report, content)
+           VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
+          [upload.id, upload.originalName, upload.sha256, upload.sizeBytes, upload.uploadedAt,
+            upload.year, upload.parserKind, upload.parserVersion, JSON.stringify(upload.report || {}), upload.content],
+        );
+      },
+      async upsertRows(rows) {
+        const now = new Date().toISOString();
+        for (const r of rows) {
+          await client.query(
+            `INSERT INTO ops_row (date, shop_key, upload_id, shop_id, shop_name, school, status,
+               order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
+             VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
+             ON CONFLICT (date, shop_key) DO UPDATE SET
+               upload_id = EXCLUDED.upload_id, shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name,
+               school = EXCLUDED.school, status = EXCLUDED.status, order_cnt = EXCLUDED.order_cnt,
+               revenue = EXCLUDED.revenue, exposure = EXCLUDED.exposure, click_rate = EXCLUDED.click_rate,
+               raw = EXCLUDED.raw, quality = EXCLUDED.quality, updated_at = EXCLUDED.updated_at`,
+            [r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school, r.status || null,
+              r.order_cnt, r.revenue, r.exposure, r.click_rate,
+              JSON.stringify(r.raw || {}), r.quality || [], now],
+          );
+        }
+      },
+      async upsertShops(shops) {
+        const now = new Date().toISOString();
+        for (const s of shops) {
+          await client.query(
+            `INSERT INTO ops_shop (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
+             VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
+             ON CONFLICT (key) DO UPDATE SET
+               shop_id = EXCLUDED.shop_id, shop_name = EXCLUDED.shop_name, school = EXCLUDED.school,
+               status = EXCLUDED.status, last_upload_id = EXCLUDED.last_upload_id, last_seen = EXCLUDED.last_seen`,
+            [s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now],
+          );
+        }
+      },
+      async deleteRowsByUpload(uploadId) {
+        await client.query('DELETE FROM ops_row WHERE upload_id = $1', [uploadId]);
+      },
+      async upsertPlatformRows(rows) {
+        const now = new Date().toISOString();
+        for (const r of rows) {
+          await client.query(
+            `INSERT INTO ops_platform_row (date, shop_id, upload_id, school, shop_name, data, updated_at)
+             VALUES ($1,$2,$3,$4,$5,$6,$7)
+             ON CONFLICT (date, shop_id) DO UPDATE SET
+               upload_id = EXCLUDED.upload_id, school = EXCLUDED.school,
+               shop_name = EXCLUDED.shop_name, data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
+            [r.date, r.shopId, r.uploadId, r.school || '', r.shopName || '', JSON.stringify(r.data || {}), now],
+          );
+        }
+      },
+      async deletePlatformRowsByUpload(uploadId) {
+        await client.query('DELETE FROM ops_platform_row WHERE upload_id = $1', [uploadId]);
+      },
+      async deletePlatformRowsBySha(sha256) {
+        await client.query('DELETE FROM ops_platform_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
+      },
+      async deleteRowsBySha(sha256) {
+        await client.query('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = $1)', [sha256]);
+      },
+      async pruneOrphanShops() {
+        await client.query('DELETE FROM ops_shop s WHERE NOT EXISTS (SELECT 1 FROM ops_row r WHERE r.shop_key = s.key)');
+      },
+      async findUploadBySha(sha256) {
+        const r = await client.query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
+        return r.rows.length ? r.rows[0].id : null;
+      },
+      async updateReport(id, report) {
+        await client.query('UPDATE ops_upload SET report = $1 WHERE id = $2', [JSON.stringify(report || {}), id]);
+      },
+    };
+  }
+
+  async function findUploadBySha(sha256) {
+    const r = await getPool().query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
+    return r.rows.length ? r.rows[0].id : null;
+  }
+
+  function uploadRowToApi(row) {
+    if (!row) return null;
+    const report = row.report || {};
+    return {
+      id: row.id,
+      originalName: row.original_name,
+      sha256: row.sha256,
+      sizeBytes: row.size_bytes,
+      uploadedAt: row.uploaded_at instanceof Date ? row.uploaded_at.toISOString() : row.uploaded_at,
+      year: row.year,
+      parserKind: row.parser_kind,
+      parserVersion: row.parser_version,
+      report,
+      rows: report.rows || 0,
+      shops: report.shops || 0,
+      warningCount: report.warningCount || 0,
+    };
+  }
+
+  async function listUploads() {
+    const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
+      parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`);
+    return r.rows.map(uploadRowToApi);
+  }
+
+  async function getUpload(id) {
+    const r = await getPool().query(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
+      parser_kind, parser_version, report FROM ops_upload WHERE id = $1`, [id]);
+    return uploadRowToApi(r.rows[0]);
+  }
+
+  async function getContent(id) {
+    const r = await getPool().query('SELECT content, original_name FROM ops_upload WHERE id = $1', [id]);
+    if (!r.rows.length) return null;
+    return { content: r.rows[0].content, originalName: r.rows[0].original_name };
+  }
+
+  async function queryRows({ from, to, schools, shops } = {}) {
+    const where = [];
+    const params = [];
+    if (from) { params.push(String(from)); where.push(`date >= $${params.length}`); }
+    if (to) { params.push(String(to)); where.push(`date <= $${params.length}`); }
+    if (Array.isArray(schools) && schools.length) { params.push(schools.map(String)); where.push(`school = ANY($${params.length})`); }
+    if (Array.isArray(shops) && shops.length) { params.push(shops.map(String)); where.push(`shop_key = ANY($${params.length})`); }
+    const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+      ORDER BY school ASC, shop_name ASC, date ASC`;
+    const r = await getPool().query(sql, params);
+    return r.rows.map(rowToApi);
+  }
+
+  async function queryPlatformRows({ from, to } = {}) {
+    const where = [];
+    const params = [];
+    if (from) { params.push(String(from)); where.push(`date >= $${params.length}`); }
+    if (to) { params.push(String(to)); where.push(`date <= $${params.length}`); }
+    const sql = `SELECT * FROM ops_platform_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY date ASC, shop_id ASC`;
+    const r = await getPool().query(sql, params);
+    return r.rows.map((row) => ({
+      date: String(row.date).trim(),
+      shopId: row.shop_id,
+      uploadId: row.upload_id,
+      school: row.school,
+      shopName: row.shop_name,
+      data: row.data || {},
+      updatedAt: row.updated_at instanceof Date ? row.updated_at.toISOString() : row.updated_at,
+    }));
+  }
+
+  async function summary() {
+    const poolRef = getPool();
+    const [uploadCount, rowCount, platformRowCount, range, matrix, schools, shops, last] = await Promise.all([
+      poolRef.query('SELECT COUNT(*)::int AS c FROM ops_upload'),
+      poolRef.query('SELECT COUNT(*)::int AS c FROM ops_row'),
+      poolRef.query('SELECT COUNT(*)::int AS c FROM ops_platform_row'),
+      poolRef.query(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
+        SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row) AS t`),
+      poolRef.query(`SELECT COUNT(*)::int AS c FROM (
+        SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row) AS t`),
+      poolRef.query(`SELECT school, COUNT(DISTINCT shop)::int AS c FROM (
+        SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
+        UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
+        UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
+      ) AS t GROUP BY school ORDER BY school ASC`),
+      poolRef.query(`SELECT key, MAX(shop_id) AS shop_id, MAX(shop_name) AS shop_name,
+        MAX(school) AS school, MAX(status) AS status FROM (
+        SELECT key, shop_id, shop_name, school, status FROM ops_shop
+        UNION ALL
+        SELECT shop_id AS key, shop_id, shop_name, school, NULL AS status FROM ops_platform_row
+      ) AS t GROUP BY key ORDER BY school ASC, shop_name ASC`),
+      poolRef.query('SELECT id, original_name, uploaded_at, report FROM ops_upload ORDER BY uploaded_at DESC LIMIT 1'),
+    ]);
+    return {
+      schools: schools.rows.map((r) => ({ name: r.school, shopCount: r.c })),
+      shops: shops.rows.map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status })),
+      uploadCount: uploadCount.rows[0].c,
+      rowCount: rowCount.rows[0].c,
+      platformRowCount: platformRowCount.rows[0].c,
+      matrixRowCount: matrix.rows[0].c,
+      coverage: range.rows[0] && range.rows[0].f ? { from: String(range.rows[0].f).replace(/-/g, ''), to: String(range.rows[0].t).replace(/-/g, '') } : null,
+      lastUpload: last.rows.length ? {
+        id: last.rows[0].id,
+        at: last.rows[0].uploaded_at instanceof Date ? last.rows[0].uploaded_at.toISOString() : last.rows[0].uploaded_at,
+        originalName: last.rows[0].original_name,
+        rows: (last.rows[0].report || {}).rows || 0,
+        warningCount: (last.rows[0].report || {}).warningCount || 0,
+      } : null,
+    };
+  }
+
+  async function getReportCache(cacheKey, dataVersion) {
+    const r = await getPool().query('SELECT report, source, data_version FROM ops_report_cache WHERE cache_key = $1', [cacheKey]);
+    if (!r.rows.length || r.rows[0].data_version !== dataVersion) return null;
+    return { report: r.rows[0].report, source: r.rows[0].source };
+  }
+
+  async function saveReportCache(record) {
+    await getPool().query(
+      `INSERT INTO ops_report_cache (cache_key, updated_at, data_version, source, report)
+       VALUES ($1,$2,$3,$4,$5)
+       ON CONFLICT (cache_key) DO UPDATE SET
+         updated_at = EXCLUDED.updated_at,
+         data_version = EXCLUDED.data_version,
+         source = EXCLUDED.source,
+         report = EXCLUDED.report`,
+      [record.cacheKey, record.updatedAt, record.dataVersion, record.source, JSON.stringify(record.report)],
+    );
+  }
+
+  async function insertReport(record) {
+    await getPool().query(
+      `INSERT INTO ops_report (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
+      [record.id, record.createdAt, record.periodFrom, record.periodTo,
+        JSON.stringify(record.schools || []), JSON.stringify(record.shops || []),
+        record.source || 'rules', record.model || '', record.title || '', JSON.stringify(record.report)],
+    );
+  }
+
+  async function listReports() {
+    const r = await getPool().query(`SELECT id, created_at, period_from, period_to, schools, shops, source, model, title, report
+      FROM ops_report ORDER BY created_at DESC LIMIT 100`);
+    return r.rows.map((row) => ({
+      id: row.id,
+      createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
+      from: String(row.period_from).trim(),
+      to: String(row.period_to).trim(),
+      source: row.source,
+      model: row.model,
+      title: row.title,
+      schools: row.schools || [],
+      shops: row.shops || [],
+      kpis: ((row.report && row.report.kpis) || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
+    }));
+  }
+
+  async function getReport(id) {
+    const r = await getPool().query('SELECT report FROM ops_report WHERE id = $1', [id]);
+    return r.rows.length ? r.rows[0].report : null;
+  }
+
+  async function deleteReport(id) {
+    const r = await getPool().query('DELETE FROM ops_report WHERE id = $1', [id]);
+    return r.rowCount;
+  }
+
+  async function close() {
+    if (pool) {
+      try { await pool.end(); } catch { /* ignore */ }
+      pool = null;
+      initPromise = null;
+    }
+  }
+
+  return {
+    kind: 'postgres',
+    init,
+    health,
+    withTransaction,
+    findUploadBySha,
+    queryPlatformRows,
+    insertReport,
+    getReportCache,
+    saveReportCache,
+    listReports,
+    getReport,
+    deleteReport,
+    listUploads,
+    getUpload,
+    getContent,
+    queryRows,
+    summary,
+    close,
+  };
+}
+
+module.exports = { createPgStore };

+ 504 - 0
server/stores/ops-sqlite.js

@@ -0,0 +1,504 @@
+'use strict';
+
+/**
+ * ops 存储 · SQLite 驱动(node:sqlite,零依赖)
+ * ----------------------------------------------------------------------------
+ * 只负责「表和读写」,不含业务判断。接口与 PG 驱动保持一致,路由层不感知方言。
+ * 需要 Node >= 22.5(node:sqlite 内置模块)。
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const SCHEMA_VERSION = '1';
+
+const DDL = [
+  `CREATE TABLE IF NOT EXISTS ops_meta (
+     key TEXT PRIMARY KEY,
+     value TEXT NOT NULL
+   )`,
+  `CREATE TABLE IF NOT EXISTS ops_upload (
+     id TEXT PRIMARY KEY,
+     original_name TEXT NOT NULL,
+     sha256 TEXT NOT NULL,
+     size_bytes INTEGER NOT NULL,
+     uploaded_at TEXT NOT NULL,
+     year INTEGER NOT NULL,
+     parser_kind TEXT NOT NULL,
+     parser_version INTEGER NOT NULL,
+     report TEXT NOT NULL DEFAULT '{}',
+     content BLOB NOT NULL
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_upload_sha256_idx ON ops_upload (sha256, uploaded_at DESC)`,
+  `CREATE TABLE IF NOT EXISTS ops_shop (
+     key TEXT PRIMARY KEY,
+     shop_id TEXT,
+     shop_name TEXT NOT NULL,
+     school TEXT NOT NULL,
+     status TEXT,
+     last_upload_id TEXT REFERENCES ops_upload(id) ON DELETE SET NULL,
+     first_seen TEXT NOT NULL,
+     last_seen TEXT NOT NULL
+   )`,
+  `CREATE TABLE IF NOT EXISTS ops_row (
+     date TEXT NOT NULL,
+     shop_key TEXT NOT NULL,
+     upload_id TEXT NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
+     shop_id TEXT,
+     shop_name TEXT NOT NULL,
+     school TEXT NOT NULL,
+     status TEXT,
+     order_cnt INTEGER,
+     revenue REAL,
+     exposure INTEGER,
+     click_rate REAL,
+     raw TEXT NOT NULL DEFAULT '{}',
+     quality TEXT NOT NULL DEFAULT '[]',
+     updated_at TEXT NOT NULL,
+     PRIMARY KEY (date, shop_key)
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date)`,
+  `CREATE TABLE IF NOT EXISTS ops_platform_row (
+     date TEXT NOT NULL,
+     shop_id TEXT NOT NULL,
+     upload_id TEXT NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
+     school TEXT NOT NULL DEFAULT '',
+     shop_name TEXT NOT NULL DEFAULT '',
+     data TEXT NOT NULL DEFAULT '{}',
+     updated_at TEXT NOT NULL,
+     PRIMARY KEY (date, shop_id)
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_platform_row_date_idx ON ops_platform_row (date)`,
+  `CREATE TABLE IF NOT EXISTS ops_report (
+     id TEXT PRIMARY KEY,
+     created_at TEXT NOT NULL,
+     period_from TEXT NOT NULL,
+     period_to TEXT NOT NULL,
+     schools TEXT NOT NULL DEFAULT '[]',
+     shops TEXT NOT NULL DEFAULT '[]',
+     source TEXT NOT NULL DEFAULT 'rules',
+     model TEXT NOT NULL DEFAULT '',
+     title TEXT NOT NULL DEFAULT '',
+     report TEXT NOT NULL
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
+  `CREATE TABLE IF NOT EXISTS ops_report_cache (
+     cache_key TEXT PRIMARY KEY,
+     updated_at TEXT NOT NULL,
+     data_version TEXT NOT NULL,
+     source TEXT NOT NULL DEFAULT 'rules',
+     report TEXT NOT NULL
+   )`,
+];
+
+function rowToApi(row) {
+  if (!row) return null;
+  return {
+    date: row.date,
+    shopKey: row.shop_key,
+    shopId: row.shop_id,
+    shopName: row.shop_name,
+    school: row.school,
+    status: row.status,
+    order_cnt: row.order_cnt,
+    revenue: row.revenue,
+    exposure: row.exposure,
+    click_rate: row.click_rate,
+    raw: safeJson(row.raw, {}),
+    quality: safeJson(row.quality, []),
+    uploadId: row.upload_id,
+    updatedAt: row.updated_at,
+  };
+}
+
+function safeJson(text, fallback) {
+  try {
+    const value = JSON.parse(text);
+    return value == null ? fallback : value;
+  } catch {
+    return fallback;
+  }
+}
+
+function createSqliteStore({ file }) {
+  const dbFile = path.resolve(String(file || 'data/ops.db'));
+  let db = null;
+  let initPromise = null;
+
+  function open() {
+    if (db) return db;
+    fs.mkdirSync(path.dirname(dbFile), { recursive: true });
+    let DatabaseSync;
+    try {
+      ({ DatabaseSync } = require('node:sqlite'));
+    } catch (e) {
+      // Node < 22.5 没有内置 sqlite:单独给一个可读原因,前端据此提示升级 Node
+      const err = new Error(`当前 Node 版本不支持内置 SQLite(需要 22.5 及以上,当前 ${process.version})`);
+      err.reason = 'sqlite-unavailable';
+      err.cause = e;
+      throw err;
+    }
+    db = new DatabaseSync(dbFile);
+    db.exec('PRAGMA journal_mode = WAL');
+    db.exec('PRAGMA busy_timeout = 5000');
+    db.exec('PRAGMA foreign_keys = ON');
+    return db;
+  }
+
+  async function init() {
+    if (!initPromise) {
+      initPromise = (async () => {
+        const handle = open();
+        for (const sql of DDL) handle.exec(sql);
+        handle.prepare('INSERT INTO ops_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
+          .run('schema_version', SCHEMA_VERSION);
+        // 建表后 checkpoint:空库阶段 data/ops.db 也是自包含的
+        try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* ignore */ }
+        return true;
+      })().catch((e) => {
+        initPromise = null; // 允许下次重试
+        throw e;
+      });
+    }
+    return initPromise;
+  }
+
+  async function health() {
+    try {
+      open().prepare('SELECT 1 AS ok').get();
+      return { ok: true, kind: 'sqlite', detail: dbFile };
+    } catch (e) {
+      return { ok: false, kind: 'sqlite', detail: e.message || String(e) };
+    }
+  }
+
+  function insertUpload(upload) {
+    open().prepare(`INSERT INTO ops_upload
+      (id, original_name, sha256, size_bytes, uploaded_at, year, parser_kind, parser_version, report, content)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
+      upload.id,
+      upload.originalName,
+      upload.sha256,
+      upload.sizeBytes,
+      upload.uploadedAt,
+      upload.year,
+      upload.parserKind,
+      upload.parserVersion,
+      JSON.stringify(upload.report || {}),
+      upload.content,
+    );
+  }
+
+  function upsertRows(rows) {
+    const stmt = open().prepare(`INSERT INTO ops_row
+      (date, shop_key, upload_id, shop_id, shop_name, school, status,
+       order_cnt, revenue, exposure, click_rate, raw, quality, updated_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+      ON CONFLICT(date, shop_key) DO UPDATE SET
+        upload_id = excluded.upload_id,
+        shop_id = excluded.shop_id,
+        shop_name = excluded.shop_name,
+        school = excluded.school,
+        status = excluded.status,
+        order_cnt = excluded.order_cnt,
+        revenue = excluded.revenue,
+        exposure = excluded.exposure,
+        click_rate = excluded.click_rate,
+        raw = excluded.raw,
+        quality = excluded.quality,
+        updated_at = excluded.updated_at`);
+    const now = new Date().toISOString();
+    for (const r of rows) {
+      stmt.run(
+        r.date, r.shopKey, r.uploadId, r.shopId || null, r.shopName, r.school,
+        r.status || null, r.order_cnt, r.revenue, r.exposure, r.click_rate,
+        JSON.stringify(r.raw || {}), JSON.stringify(r.quality || []), now,
+      );
+    }
+  }
+
+  function upsertShops(shops) {
+    const stmt = open().prepare(`INSERT INTO ops_shop
+      (key, shop_id, shop_name, school, status, last_upload_id, first_seen, last_seen)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+      ON CONFLICT(key) DO UPDATE SET
+        shop_id = excluded.shop_id,
+        shop_name = excluded.shop_name,
+        school = excluded.school,
+        status = excluded.status,
+        last_upload_id = excluded.last_upload_id,
+        last_seen = excluded.last_seen`);
+    const now = new Date().toISOString();
+    for (const s of shops) {
+      stmt.run(s.key, s.shopId || null, s.shopName, s.school, s.status || null, s.lastUploadId || null, now, now);
+    }
+  }
+
+  function deleteRowsByUpload(uploadId) {
+    open().prepare('DELETE FROM ops_row WHERE upload_id = ?').run(uploadId);
+  }
+
+  function upsertPlatformRows(rows) {
+    const stmt = open().prepare(`INSERT INTO ops_platform_row
+      (date, shop_id, upload_id, school, shop_name, data, updated_at)
+      VALUES (?, ?, ?, ?, ?, ?, ?)
+      ON CONFLICT(date, shop_id) DO UPDATE SET
+        upload_id = excluded.upload_id,
+        school = excluded.school,
+        shop_name = excluded.shop_name,
+        data = excluded.data,
+        updated_at = excluded.updated_at`);
+    const now = new Date().toISOString();
+    for (const r of rows) {
+      stmt.run(r.date, r.shopId, r.uploadId, r.school || '', r.shopName || '', JSON.stringify(r.data || {}), now);
+    }
+  }
+
+  function deletePlatformRowsByUpload(uploadId) {
+    open().prepare('DELETE FROM ops_platform_row WHERE upload_id = ?').run(uploadId);
+  }
+
+  function deletePlatformRowsBySha(sha256) {
+    open().prepare('DELETE FROM ops_platform_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = ?)').run(sha256);
+  }
+
+  async function queryPlatformRows({ from, to } = {}) {
+    const where = [];
+    const params = [];
+    if (from) { where.push('date >= ?'); params.push(String(from)); }
+    if (to) { where.push('date <= ?'); params.push(String(to)); }
+    const sql = `SELECT * FROM ops_platform_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY date ASC, shop_id ASC`;
+    return open().prepare(sql).all(...params).map((row) => ({
+      date: row.date,
+      shopId: row.shop_id,
+      uploadId: row.upload_id,
+      school: row.school,
+      shopName: row.shop_name,
+      data: safeJson(row.data, {}),
+      updatedAt: row.updated_at,
+    }));
+  }
+
+  function deleteRowsBySha(sha256) {
+    open().prepare('DELETE FROM ops_row WHERE upload_id IN (SELECT id FROM ops_upload WHERE sha256 = ?)').run(sha256);
+  }
+
+  function pruneOrphanShops() {
+    open().prepare('DELETE FROM ops_shop WHERE key NOT IN (SELECT DISTINCT shop_key FROM ops_row)').run();
+  }
+
+  function updateReport(id, report) {
+    open().prepare('UPDATE ops_upload SET report = ? WHERE id = ?').run(JSON.stringify(report || {}), id);
+  }
+
+  async function withTransaction(fn) {
+    const handle = open();
+    handle.exec('BEGIN IMMEDIATE');
+    try {
+      const result = await fn({ insertUpload, upsertRows, upsertShops, upsertPlatformRows, deleteRowsByUpload, deleteRowsBySha, deletePlatformRowsByUpload, deletePlatformRowsBySha, pruneOrphanShops, updateReport, findUploadBySha });
+      handle.exec('COMMIT');
+      // 写事务结束后做一次 checkpoint:让 data/ops.db 自包含,备份时不必关心 -wal
+      try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* 有读事务时跳过 */ }
+      return result;
+    } catch (e) {
+      try { handle.exec('ROLLBACK'); } catch { /* 事务可能已结束 */ }
+      throw e;
+    }
+  }
+
+  async function findUploadBySha(sha256) {
+    const row = open().prepare('SELECT id FROM ops_upload WHERE sha256 = ? ORDER BY uploaded_at DESC LIMIT 1').get(sha256);
+    return row ? row.id : null;
+  }
+
+  function uploadRowToApi(row) {
+    if (!row) return null;
+    const report = safeJson(row.report, {});
+    return {
+      id: row.id,
+      originalName: row.original_name,
+      sha256: row.sha256,
+      sizeBytes: row.size_bytes,
+      uploadedAt: row.uploaded_at,
+      year: row.year,
+      parserKind: row.parser_kind,
+      parserVersion: row.parser_version,
+      report,
+      rows: report.rows || 0,
+      shops: report.shops || 0,
+      warningCount: report.warningCount || 0,
+    };
+  }
+
+  async function listUploads() {
+    return open().prepare(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
+      parser_kind, parser_version, report FROM ops_upload ORDER BY uploaded_at DESC`).all().map(uploadRowToApi);
+  }
+
+  async function getUpload(id) {
+    const row = open().prepare(`SELECT id, original_name, sha256, size_bytes, uploaded_at, year,
+      parser_kind, parser_version, report FROM ops_upload WHERE id = ?`).get(id);
+    return uploadRowToApi(row);
+  }
+
+  async function getContent(id) {
+    const row = open().prepare('SELECT content, original_name FROM ops_upload WHERE id = ?').get(id);
+    if (!row) return null;
+    return { content: Buffer.from(row.content), originalName: row.original_name };
+  }
+
+  async function queryRows({ from, to, schools, shops } = {}) {
+    const where = [];
+    const params = [];
+    if (from) { where.push('date >= ?'); params.push(String(from)); }
+    if (to) { where.push('date <= ?'); params.push(String(to)); }
+    if (Array.isArray(schools) && schools.length) {
+      where.push(`school IN (${schools.map(() => '?').join(',')})`);
+      params.push(...schools.map(String));
+    }
+    if (Array.isArray(shops) && shops.length) {
+      where.push(`shop_key IN (${shops.map(() => '?').join(',')})`);
+      params.push(...shops.map(String));
+    }
+    const sql = `SELECT * FROM ops_row ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
+      ORDER BY school ASC, shop_name ASC, date ASC`;
+    return open().prepare(sql).all(...params).map(rowToApi);
+  }
+
+  async function summary() {
+    const handle = open();
+    const uploadCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_upload').get().c;
+    const rowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_row').get().c;
+    const platformRowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_platform_row').get().c;
+    const range = handle.prepare(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
+      SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row)`).get();
+    const matrixRowCount = handle.prepare(`SELECT COUNT(*) AS c FROM (
+      SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row)`).get().c;
+    const schools = handle.prepare(`SELECT school, COUNT(DISTINCT shop) AS c FROM (
+        SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
+        UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
+        UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
+      ) GROUP BY school ORDER BY school ASC`)
+      .all().map((r) => ({ name: r.school, shopCount: r.c }));
+    const shops = handle.prepare(`SELECT key, MAX(shop_id) AS shop_id, MAX(shop_name) AS shop_name,
+        MAX(school) AS school, MAX(status) AS status FROM (
+        SELECT key, shop_id, shop_name, school, status FROM ops_shop
+        UNION ALL
+        SELECT shop_id AS key, shop_id, shop_name, school, NULL AS status FROM ops_platform_row
+      ) GROUP BY key ORDER BY school ASC, shop_name ASC`)
+      .all().map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status }));
+    const last = handle.prepare(`SELECT id, original_name, uploaded_at, report FROM ops_upload
+      ORDER BY uploaded_at DESC LIMIT 1`).get();
+    return {
+      schools,
+      shops,
+      uploadCount,
+      rowCount,
+      platformRowCount,
+      matrixRowCount,
+      coverage: range && range.f ? { from: range.f, to: range.t } : null,
+      lastUpload: last ? {
+        id: last.id,
+        at: last.uploaded_at,
+        originalName: last.original_name,
+        rows: (safeJson(last.report, {}) || {}).rows || 0,
+        warningCount: (safeJson(last.report, {}) || {}).warningCount || 0,
+      } : null,
+    };
+  }
+
+  async function getReportCache(cacheKey, dataVersion) {
+    const row = open().prepare('SELECT report, source, data_version FROM ops_report_cache WHERE cache_key = ?').get(cacheKey);
+    if (!row || row.data_version !== dataVersion) return null;
+    return { report: safeJson(row.report, null), source: row.source };
+  }
+
+  async function saveReportCache(record) {
+    open().prepare(`INSERT INTO ops_report_cache (cache_key, updated_at, data_version, source, report)
+      VALUES (?, ?, ?, ?, ?)
+      ON CONFLICT(cache_key) DO UPDATE SET
+        updated_at = excluded.updated_at,
+        data_version = excluded.data_version,
+        source = excluded.source,
+        report = excluded.report`).run(
+      record.cacheKey, record.updatedAt, record.dataVersion, record.source, JSON.stringify(record.report),
+    );
+  }
+
+  async function insertReport(record) {
+    open().prepare(`INSERT INTO ops_report
+      (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
+      record.id, record.createdAt, record.periodFrom, record.periodTo,
+      JSON.stringify(record.schools || []), JSON.stringify(record.shops || []),
+      record.source || 'rules', record.model || '', record.title || '',
+      JSON.stringify(record.report),
+    );
+  }
+
+  async function listReports() {
+    return open().prepare(`SELECT id, created_at, period_from, period_to, schools, shops, source, model, title, report
+      FROM ops_report ORDER BY created_at DESC LIMIT 100`).all().map((row) => {
+      const report = safeJson(row.report, {});
+      return {
+        id: row.id,
+        createdAt: row.created_at,
+        from: row.period_from,
+        to: row.period_to,
+        source: row.source,
+        model: row.model,
+        title: row.title,
+        schools: safeJson(row.schools, []),
+        shops: safeJson(row.shops, []),
+        kpis: (report.kpis || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
+      };
+    });
+  }
+
+  async function getReport(id) {
+    const row = open().prepare('SELECT report FROM ops_report WHERE id = ?').get(id);
+    return row ? safeJson(row.report, null) : null;
+  }
+
+  async function deleteReport(id) {
+    return open().prepare('DELETE FROM ops_report WHERE id = ?').run(id).changes;
+  }
+
+  async function close() {
+    if (db) {
+      try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* ignore */ }
+      try { db.close(); } catch { /* ignore */ }
+      db = null;
+      initPromise = null;
+    }
+  }
+
+  return {
+    kind: 'sqlite',
+    file: dbFile,
+    init,
+    health,
+    withTransaction,
+    insertUpload,
+    upsertRows,
+    upsertShops,
+    deleteRowsByUpload,
+    updateReport,
+    findUploadBySha,
+    queryPlatformRows,
+    insertReport,
+    getReportCache,
+    saveReportCache,
+    listReports,
+    getReport,
+    deleteReport,
+    listUploads,
+    getUpload,
+    getContent,
+    queryRows,
+    summary,
+    close,
+  };
+}
+
+module.exports = { createSqliteStore };

+ 73 - 5
启动.bat

@@ -1,8 +1,76 @@
 @echo off
+chcp 65001 >nul
+title 智慧运营中台
+setlocal
 cd /d "%~dp0"
-if not exist node_modules (
-  echo 正在安装依赖...
-  call npm install
-)
-echo 正在启动智慧运营中台...
+
+echo ============================================
+echo   智慧运营中台 - Windows 启动器
+echo   目录: %CD%
+echo ============================================
+echo.
+
+rem Add common Node.js install paths for double-click launch
+set "NODE_EXE="
+if exist "%ProgramFiles%\nodejs\node.exe" set "NODE_EXE=%ProgramFiles%\nodejs\node.exe"
+if exist "%ProgramFiles(x86)%\nodejs\node.exe" set "NODE_EXE=%ProgramFiles(x86)%\nodejs\node.exe"
+if exist "%NVM_SYMLINK%\node.exe" set "NODE_EXE=%NVM_SYMLINK%\node.exe"
+if exist "%LOCALAPPDATA%\Programs\nodejs\node.exe" set "NODE_EXE=%LOCALAPPDATA%\Programs\nodejs\node.exe"
+if not defined NODE_EXE goto check_node
+for %%i in ("%NODE_EXE%") do set "PATH=%%~dpi;%PATH%"
+:check_node
+
+where node >nul 2>nul
+if errorlevel 1 goto no_node
+
+node -e "const v=process.versions.node.split('.').map(Number);if(v[0]<22||(v[0]==22&&v[1]<13)){process.exit(1)}"
+if errorlevel 1 goto bad_node
+
+where npm >nul 2>nul
+if errorlevel 1 goto no_npm
+
+echo Node 版本:
+node -v
+echo.
+
+if exist node_modules goto start
+
+echo 首次启动,正在安装依赖(需要联网,可能需要几分钟)...
+call npm install
+if errorlevel 1 goto install_fail
+
+:start
+echo 正在启动服务,请保持本窗口不要关闭...
+echo 浏览器访问: http://localhost:5177
+echo.
 call npm start
+echo.
+echo 服务已退出。
+goto end
+
+:no_node
+echo [错误] 未检测到 Node.js。
+echo 请安装 Node.js 22.13 或更高版本: https://nodejs.org/
+echo 安装后重新打开本窗口再试。
+goto end
+
+:bad_node
+echo [错误] Node.js 版本过低:
+node -v
+echo 需要 22.13 或更高版本,请升级后重试。
+goto end
+
+:no_npm
+echo [错误] 未检测到 npm(通常随 Node.js 一起安装)。
+goto end
+
+:install_fail
+echo [错误] 依赖安装失败。
+echo 请检查网络,或在命令行手动执行: npm install
+goto end
+
+:end
+echo.
+echo 按任意键关闭窗口...
+pause >nul
+exit /b 0

+ 48 - 4
启动.command

@@ -1,8 +1,52 @@
 #!/bin/bash
-cd "$(dirname "$0")"
+# macOS 启动器(Windows 请用 启动.bat)
+cd "$(dirname "$0")" || exit 1
+
+# Finder 双击不会加载 shell 配置:补上 Homebrew / nvm / volta 常见路径
+export PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.volta/bin:$HOME/.local/bin:$PATH"
+if [ -s "$HOME/.nvm/nvm.sh" ]; then
+  # shellcheck disable=SC1090
+  . "$HOME/.nvm/nvm.sh" >/dev/null 2>&1
+fi
+
+fail() {
+  echo ""
+  echo "[错误] $1"
+  echo ""
+  if [ -t 0 ]; then
+    read -n 1 -s -r -p "按任意键关闭窗口…"
+    echo ""
+  fi
+  exit 1
+}
+
+echo "============================================"
+echo "  智慧运营中台 - macOS 启动器"
+echo "  目录:$(pwd)"
+echo "============================================"
+echo ""
+
+command -v node >/dev/null 2>&1 || fail "未检测到 Node.js。请安装 Node.js 22.13 或更高版本:https://nodejs.org/"
+command -v npm >/dev/null 2>&1 || fail "未检测到 npm(通常随 Node.js 一起安装)。"
+
+node -e "const [a,b]=process.versions.node.split('.').map(Number);if(a<22||(a===22&&b<13)){console.error('当前 Node.js '+process.version+' 过低,需要 22.13 及以上。');process.exit(1)}" \
+  || fail "Node.js 版本过低:$(node -v),需要 22.13 及以上。"
+
+echo "使用 Node:$(command -v node) ($(node -v))"
+echo ""
+
 if [ ! -d node_modules ]; then
-  echo "正在安装依赖..."
-  npm install
+  echo "正在安装依赖(首次启动需要几分钟)..."
+  npm install || fail "依赖安装失败,请检查网络后重试。"
 fi
-echo "正在启动智慧运营中台..."
+
+echo "正在启动服务,启动后请保持本窗口不要关闭..."
+echo "浏览器访问:http://localhost:5177"
+echo ""
 npm start
+
+echo ""
+if [ -t 0 ]; then
+  read -n 1 -s -r -p "服务已退出,按任意键关闭窗口…"
+  echo ""
+fi

+ 9 - 2
启动.sh

@@ -1,8 +1,15 @@
 #!/bin/bash
-cd "$(dirname "$0")"
+cd "$(dirname "$0")" || exit 1
+
+if ! command -v node >/dev/null 2>&1; then
+  echo "未检测到 Node.js。请先安装 Node.js 22.13 或更高版本:https://nodejs.org/"
+  exit 1
+fi
+node -e "const [a,b]=process.versions.node.split('.').map(Number);if(a<22||(a===22&&b<13)){console.error('当前 Node.js '+process.version+' 过低,需要 22.13 及以上。');process.exit(1)}" || exit 1
+
 if [ ! -d node_modules ]; then
   echo "正在安装依赖..."
-  npm install
+  npm install || exit 1
 fi
 echo "正在启动智慧运营中台..."
 npm start

+ 41 - 6
打包分发.bat

@@ -2,27 +2,62 @@
 chcp 65001 >nul
 cd /d "%~dp0"
 set NAME=智慧运营中台-分发包
-set OUT=..\%NAME%
-set ZIP=..\%NAME%.zip
+set OUT=%CD%\dist\%NAME%
+set ZIP=%CD%\dist\%NAME%.zip
 
 if exist "%OUT%" rmdir /s /q "%OUT%"
 if exist "%ZIP%" del /q "%ZIP%"
+if not exist "dist" mkdir "dist"
 mkdir "%OUT%"
 
-for %%f in (server.js napos-service.js reports-store.js reports-mcp.js nl-query.js report-html.js imageMeta.js package.json package-lock.json config.example.json README.md 启动.bat) do (
+rem 根目录代码:复制全部 *.js,新增模块不会漏
+for %%f in (*.js) do (
+  copy /y "%%f" "%OUT%\" >nul
+)
+
+for %%f in (package.json package-lock.json config.example.json README.md 启动.bat 启动.command 启动.sh 打包分发.bat 打包分发.command) do (
   if exist "%%f" copy /y "%%f" "%OUT%\" >nul
 )
+
 robocopy public "%OUT%\public" /E /NFL /NDL /NJH /NJS >nul
 robocopy napos "%OUT%\napos" /E /NFL /NDL /NJH /NJS >nul
 robocopy server "%OUT%\server" /E /NFL /NDL /NJH /NJS >nul
 robocopy scripts "%OUT%\scripts" /E /NFL /NDL /NJH /NJS >nul
 robocopy node_modules "%OUT%\node_modules" /E /NFL /NDL /NJH /NJS >nul
 
+rem 敏感文件与数据默认不进包
+if exist "%OUT%\config.json" del /q "%OUT%\config.json"
+if exist "%OUT%\session.json" del /q "%OUT%\session.json"
+if exist "%OUT%\manual-shops.json" del /q "%OUT%\manual-shops.json"
+if exist "%OUT%\ops-export.json" del /q "%OUT%\ops-export.json"
+if exist "%OUT%\data" rmdir /s /q "%OUT%\data"
+if exist "%OUT%\workspace" rmdir /s /q "%OUT%\workspace"
+
+rem 可选:带上演示数据库(双击运行时默认不带;需要时在命令行执行 打包分发.bat --with-data)
+if /i "%~1"=="--with-data" (
+  if exist "data\ops.db" (
+    mkdir "%OUT%\data"
+    copy /y "data\ops.db" "%OUT%\data\ops.db" >nul
+    echo 已包含演示数据库 data\ops.db
+  )
+)
+
+pushd "%OUT%"
+node -e "require('./server/routes/index.js')" >nul 2>nul
+if errorlevel 1 (
+  echo 打包失败:分发包缺少运行期模块,请检查根目录 *.js 是否完整。
+  popd
+  rmdir /s /q "%OUT%"
+  pause
+  exit /b 1
+)
+popd
+
 powershell -NoProfile -Command "Compress-Archive -LiteralPath '%OUT%' -DestinationPath '%ZIP%' -Force"
 if exist "%ZIP%" rmdir /s /q "%OUT%"
 
 echo 已生成分发包: %ZIP%
-echo 包内不含 config.json / session.json / manual-shops.json / data/
-echo (API Key、Cookie、报表数据只留在你本机)
-echo 对方解压后双击 启动.bat 即可,首次启动自动生成其本机配置。
+echo 默认不含 config.json / data / workspace(密钥与数据只留在本机)。
+echo 对方解压后双击 启动.bat;首次启动自动生成配置与空数据库。
+echo 要求 Node.js 22.13 及以上。
 pause

+ 59 - 18
打包分发.command

@@ -1,31 +1,64 @@
 #!/bin/bash
 # 一键打包分发:只打代码与运行所需文件,本机密钥与数据(config.json / session.json /
-# manual-shops.json / data/ 等)绝不进包 —— 对方启动后会自动生成他们本机的配置。
+# manual-shops.json / ops-export.json / data/ / workspace/)默认不进包 —— 对方启动后
+# 会自动生成他们本机的配置和空数据库。
+#
+# 用法:
+#   ./打包分发.command                 仅代码(推荐)
+#   ./打包分发.command --with-data     额外带上当前 data/ops.db(演示数据,注意脱敏)
 cd "$(dirname "$0")" || exit 1
 
 NAME="智慧运营中台-分发包"
-OUT="../$NAME"
-ZIP="../$NAME.zip"
+OUT="dist/$NAME"
+ZIP="dist/$NAME.zip"
+WITH_DATA=0
+[ "$1" = "--with-data" ] && WITH_DATA=1
 
-rm -rf "$OUT" "$ZIP"
-mkdir -p "$OUT"
+rm -rf "$OUT" "$ZIP" || exit 1
+mkdir -p "$OUT" "dist" || exit 1
 
-# 白名单文件(代码 / 模板 / 启动脚本 / 文档)
-for f in server.js napos-service.js reports-store.js reports-mcp.js nl-query.js report-html.js imageMeta.js \
-         package.json package-lock.json config.example.json README.md \
-         启动.command 启动.sh 启动.bat; do
+# 根目录代码:用通配更稳,新增模块不会漏
+for f in *.js; do
+  case "$f" in
+    config.js|session.js) continue ;;
+  esac
   [ -f "$f" ] && cp "$f" "$OUT/"
 done
 
-# 目录(前端 / 取数引擎 / 依赖)
+# 配置文件与启动脚本
+for f in package.json package-lock.json config.example.json README.md \
+         启动.command 启动.sh 启动.bat 打包分发.command 打包分发.bat; do
+  [ -f "$f" ] && cp "$f" "$OUT/"
+done
+
+# 目录(前端 / 服务端 / 脚本 / 依赖)
 for d in public server scripts napos node_modules; do
   [ -d "$d" ] && cp -R "$d" "$OUT/"
 done
 
+# 兜底:任何本机敏感文件都不进包
+rm -f "$OUT/config.json" "$OUT/session.json" "$OUT/manual-shops.json" "$OUT/ops-export.json"
+rm -rf "$OUT/data" "$OUT/workspace"
 find "$OUT" -name ".DS_Store" -delete 2>/dev/null
 
+if [ "$WITH_DATA" = "1" ] && [ -f data/ops.db ]; then
+  mkdir -p "$OUT/data"
+  # checkpoint 后再复制,保证 ops.db 自包含
+  node -e "const {DatabaseSync}=require('node:sqlite');const db=new DatabaseSync('data/ops.db');db.exec('PRAGMA wal_checkpoint(TRUNCATE)');db.close();" 2>/dev/null || true
+  cp data/ops.db "$OUT/data/ops.db"
+  echo "已包含演示数据库:data/ops.db(请确认数据可外发)"
+fi
+
+# 打包前自检:缺任何一个运行期模块都直接失败,避免发出坏包
+if ! ( cd "$OUT" && node -e "require('./server/routes/index.js')" >/dev/null 2>&1 ); then
+  echo "打包失败:分发包缺少运行期模块(请在包目录执行 node -e \"require('./server/routes/index.js')\" 查看具体缺失文件)"
+  rm -rf "$OUT"
+  exit 1
+fi
+
 (
-  cd .. || exit 1
+  cd dist || exit 1
+  rm -f "$NAME.zip"
   zip -qr "$NAME.zip" "$NAME"
 )
 
@@ -34,10 +67,18 @@ if [ ! -f "$ZIP" ]; then
 fi
 rm -rf "$OUT"
 
-echo "已生成分发包: $ZIP"
-echo "包内不含 config.json / session.json / manual-shops.json / data/"
-echo "(API Key、Cookie、报表数据只留在你本机)"
-echo "对方解压后双击 启动.command(macOS)或 启动.bat(Windows)即可,"
-echo "首次启动会自动生成他们本机的配置,并在「系统设置」里填自己的 API Key。"
-echo "若对方电脑装过旧版:把包解压覆盖到原目录即可(不要先删旧目录),"
-echo "其历史 config.json / session.json 会被新版直接读取,API Key 与 Cookie 无需重填。"
+SIZE=$(du -sh "$ZIP" | cut -f1)
+echo ""
+echo "已生成分发包: $ZIP($SIZE)"
+if [ "$WITH_DATA" = "1" ]; then
+  echo "包含:代码 + node_modules + 演示数据库"
+else
+  echo "包含:代码 + node_modules(不含 config.json / data / workspace)"
+fi
+echo ""
+echo "对方解压后:"
+echo "  1) 双击 启动.command(macOS)或 启动.bat(Windows)"
+echo "  2) 首次启动自动生成 config.json 与空数据库"
+echo "  3) 在「系统设置」里填自己的 API Key"
+echo ""
+echo "要求 Node.js >= 22.13(内置 SQLite 驱动);装过旧版可直接覆盖解压。"

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov