Просмотр исходного кода

feat: 店铺运营数据上传(xlsx 解析 + SQLite 存储 + 报表/卡片视图)

采集之外新增文件上传入口,整条链路本地可跑通:

- 解析:零依赖 xlsx 读取器(ZIP + XML),人工统计横表 → 长表;
  清洗 10..6% / 12.3 / 未开放 / 空值,保留原文与质量标记
- 存储:统一接口 + 双驱动(node:sqlite 默认 / pg 壳),原件 bytea 入库,
  事务写入 + ON CONFLICT 覆盖,可按 uploadId 重新解析
- 接口:/api/ops/{upload,summary,rows,uploads,uploads/:id/file,uploads/:id/reparse,export}
  存储不可用时仅本功能降级(summary enabled:false,其余 503)
- 页面:「店铺运营」菜单,报表矩阵(按学校分组/小计/异常标记)与卡片视图,
  学校/日期/指标筛选、CSV 导出、上传弹窗、导入记录
- 脚本:db:init / db:export / db:import(SQLite ↔ PG 搬迁)、test:ops
- 测试:30 条(解析器 12 + 端到端 18,临时库不依赖网络)
- smoke:新增 3 条只读断言;顺带把 rootDir/storage 指到临时目录,
  冒烟不再依赖本机 workspace(原来会因目录不存在挂 2 条)
liangzhao 2 недель назад
Родитель
Сommit
a5368115b6

+ 1 - 0
.gitignore

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

+ 16 - 0
README.md

@@ -43,9 +43,21 @@
 - **智能问数对话记忆**:问数对话按数据主体保存在浏览器本地,刷新或重开页面后可继续追问,「清空对话」同时清除本地记录。
 - **字段**:日期、门店名称、有效订单、收入、营业额、曝光人数、下单人数、进店转化率、下单转化率、客单价、支出、活动订单、活动补贴等;「营业时长 / 异常关店时长 / 商品销售额」为预留列,待后续数据源接入后自动回填。
 
+### 店铺运营数据导入
+
+采集之外的另一条数据入口:直接上传商家后台导出的人工统计表,不依赖商家客户端。
+
+- **支持格式**:一行一店的横表——`学校名称 / 店铺名称 / ID / 上线状态` + 每天 4 列(订单量 / 营收 / 曝光量 / 进店转换率)。日期列写成 `9.9订单量` 这种形式即可,年份在上传时选择。
+- **原件存档**:每次上传的 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`)。
 
@@ -155,6 +167,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 +177,7 @@ workspace/
 │   │   ├── generate.js    #   视觉生成、任务中心(含采集任务)、画板保存、打开目录
 │   │   ├── history.js     #   门店扫描(图片尺寸缓存)、历史创作、图片文件服务
 │   │   ├── reports.js     #   数据采集 + 智能问数(同步/查询/导出/周报)
+│   │   ├── ops.js         #   店铺运营数据:上传/解析/矩阵查询/原件下载
 │   │   ├── static.js      #   静态前端(精确白名单 + /assets/)
 │   │   └── index.js       #   路由注册总入口
 │   └── services/          # 有状态引擎
@@ -208,6 +223,7 @@ workspace/
 | `config.json` | API Key、模型、端口等 | 系统设置保存时写入;首次启动若缺失会自动从模板生成 |
 | `session.json` | 淘宝闪购采集 Cookie(含 `ksid`) | 仅门店采集使用;界面保存后不回显 |
 | `data/reports/` | 经营数据本地数据湖 | 各主体经营数据,按「日期 × 门店」沉淀 |
+| `data/ops.db` | 店铺运营数据(上传解析) | SQLite;含原件与解析行,备份 `data/` 时一并保留 |
 | `manual-shops.json` | 手动店铺清单 | 门店采集用 |
 
 以上文件均不进 Git;数据采集的取数不依赖 Cookie —— 它实时读取本机商家客户端的登录态(调试端口 9669)。

+ 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 → 重启服务数据仍在

+ 4 - 1
docs/superpowers/specs/2026-10-20-manual-ops-import-design.md

@@ -301,7 +301,10 @@ SQLite 连接参数:`journal_mode=WAL`、`busy_timeout=5000`、`foreign_keys=O
 跳过(不产生 warning,属正常结构):
 
 - 店铺名称为空(含 6 个占位行)
-- 整行所有指标均为空
+- 该店在所有日期上都没有任何一个数值(整行空店)
+
+有数据的店保留全部日期,当天四项皆空也留一行(值全为 `NULL`),矩阵显示「–」,
+保证「店 × 天」结构完整(36 × 29 = 1044 行)。
 
 保留但标记:
 

+ 325 - 0
ops-import.js

@@ -0,0 +1,325 @@
+'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;
+}
+
+/**
+ * 日期列 → [{ 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 }} options
+ * @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 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 = 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 detectBuffer(buffer) {
+  const { sheets } = readXlsx(buffer);
+  const cells = sheets.length ? sheets[0].rows : [];
+  for (const parser of PARSERS) {
+    if (parser.detect(cells)) return parser.kind;
+  }
+  return null;
+}
+
+module.exports = {
+  METRICS,
+  METRIC_KEYS,
+  PARSERS,
+  detect,
+  detectBuffer,
+  parseManualOps,
+  parseWorkbook,
+  cleanValue,
+  parseDateColumns,
+};

+ 132 - 0
ops-store.js

@@ -0,0 +1,132 @@
+'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 || {};
+      const connectionString = process.env.OPS_DATABASE_URL || pgCfg.connectionString || '';
+      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'),
+    summary: call('summary'),
+    get failure() {
+      return lastFailure;
+    },
+  };
+}
+
+module.exports = { createOpsStore, pickDriver };

+ 5 - 1
package.json

@@ -5,7 +5,11 @@
   "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"
   },
   "dependencies": {
     "jpeg-js": "^0.4.4",

+ 2 - 0
public/app.js

@@ -34,6 +34,7 @@
     tasks: document.getElementById('viewTasks'),
     history: document.getElementById('viewHistory'),
     reports: document.getElementById('viewReports'),
+    ops: document.getElementById('viewOps'),
     lake: document.getElementById('viewLake'),
     analysis: document.getElementById('viewAnalysis'),
     ask: document.getElementById('viewAsk'),
@@ -399,6 +400,7 @@
     if (window.shanTasksSetActive) window.shanTasksSetActive(name === 'tasks'); // 离开任务页清掉轮询
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
+    if (name === 'ops' && window.shanOpsInit) window.shanOpsInit();
     if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表
     if (name === 'analysis' && window.shanAnalysisActivate) window.shanAnalysisActivate();
     if (name === 'ask' && window.shanAskInit) window.shanAskInit();

+ 63 - 0
public/index.html

@@ -40,6 +40,7 @@
 
     <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="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 +327,28 @@
       </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">
@@ -1049,6 +1072,45 @@
   </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>或点击选择文件;支持商家后台导出的人工统计表</small>
+      </div>
+      <input type="file" id="opsFile" accept=".xlsx" hidden>
+      <div class="ops-file-name" id="opsFileName"></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-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 +1119,7 @@
 <script src="/analysis-templates.js"></script>
 <script src="/reports.js"></script>
 <script src="/lake.js"></script>
+<script src="/ops.js"></script>
 <script src="/ask.js"></script>
 <script src="/app.js"></script>
 </body>

+ 574 - 0
public/ops.js

@@ -0,0 +1,574 @@
+'use strict';
+
+// 店铺运营页:上传人工统计表 → 按学校/店铺/日期看指标矩阵或卡片。
+// 数据来自 /api/ops/*;原件存服务端存储(默认本地 SQLite),页面只做展示与上传。
+(function () {
+  const $ = (id) => document.getElementById(id);
+
+  const META = {
+    order_cnt: { label: '订单量', type: 'int' },
+    revenue: { label: '营收', type: 'money' },
+    exposure: { label: '曝光量', type: 'int' },
+    click_rate: { label: '进店转化率', type: 'rate' },
+  };
+  const ALL_METRICS = Object.keys(META);
+  const DEFAULT_METRICS = ALL_METRICS.slice();
+
+  function loadJSON(key, fallback) {
+    try {
+      const raw = localStorage.getItem(key);
+      if (!raw) return fallback;
+      const v = JSON.parse(raw);
+      return Array.isArray(v) && v.length ? v.filter((x) => ALL_METRICS.includes(x)) : fallback;
+    } catch {
+      return fallback;
+    }
+  }
+
+  const state = {
+    inited: false,
+    loading: false,
+    summary: null,
+    data: null,
+    schools: null, // null = 全选
+    metrics: loadJSON('ops.metrics', DEFAULT_METRICS),
+    view: localStorage.getItem('ops.view') === 'cards' ? 'cards' : 'table',
+    from: '',
+    to: '',
+    upload: { file: null, busy: 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, metric) {
+    if (value === null || value === undefined) return '–';
+    const n = Number(value);
+    if (!Number.isFinite(n)) return '–';
+    if (META[metric].type === 'rate') return (n * 100).toFixed(1) + '%';
+    if (META[metric].type === 'money') return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+    return n.toLocaleString('zh-CN');
+  }
+
+  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) {
+      if (!state.from) state.from = state.summary.coverage.from;
+      if (!state.to) state.to = state.summary.coverage.to;
+    }
+    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(','));
+    params.set('metrics', state.metrics.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 || !summary.rowCount) {
+      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-chips" id="opsMetricChips">
+            ${ALL_METRICS.map((k) => `<button type="button" class="ops-chip${state.metrics.includes(k) ? ' on' : ''}" data-metric="${k}">${META[k].label}</button>`).join('')}
+          </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();
+      });
+    });
+    bar.querySelectorAll('[data-metric]').forEach((btn) => {
+      btn.addEventListener('click', () => {
+        const key = btn.dataset.metric;
+        if (state.metrics.includes(key)) {
+          if (state.metrics.length === 1) return; // 至少保留一个指标
+          state.metrics = state.metrics.filter((k) => k !== key);
+        } else {
+          state.metrics = ALL_METRICS.filter((k) => key === k || state.metrics.includes(k));
+        }
+        localStorage.setItem('ops.metrics', JSON.stringify(state.metrics));
+        renderBar();
+        renderBody();
+      });
+    });
+    $('opsFrom').addEventListener('change', (e) => { state.from = inputToYmd(e.target.value); loadRows(); });
+    $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
+  }
+
+  function renderHeaderTools() {
+    const el = $('opsHeaderTools');
+    if (!el) return;
+    const summary = state.summary;
+    if (!summary || !summary.enabled || !summary.rowCount) {
+      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(','));
+      params.set('metrics', state.metrics.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;
+    }
+    if (!summary.rowCount) {
+      body.innerHTML = `
+        <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);
+  }
+
+  function formatCell(shop, metric, date) {
+    const series = shop.series[metric] || {};
+    const value = Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
+    const quality = (shop.quality || {})[date];
+    const warn = quality && quality.length
+      ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
+      : '';
+    return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, metric)}${warn}</td>`;
+  }
+
+  function subtotalRow(group, metric, dates) {
+    const cells = dates.map((date) => {
+      if (metric === 'click_rate') {
+        let weight = 0;
+        let acc = 0;
+        for (const shop of group.shops) {
+          const exposure = (shop.series.exposure || {})[date];
+          const rate = (shop.series.click_rate || {})[date];
+          if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
+            weight += exposure;
+            acc += rate * exposure;
+          }
+        }
+        return `<td class="ops-num ops-subtotal">${weight > 0 ? fmtValue(acc / weight, metric) : '–'}</td>`;
+      }
+      let sum = 0;
+      let seen = false;
+      for (const shop of group.shops) {
+        const v = (shop.series[metric] || {})[date];
+        if (typeof v === 'number') { sum += v; seen = true; }
+      }
+      return `<td class="ops-num ops-subtotal">${seen ? fmtValue(sum, metric) : '–'}</td>`;
+    }).join('');
+    return `<tr class="ops-subtotal-row"><td class="ops-shop-cell">${esc(group.school)} · 小计</td>${cells}</tr>`;
+  }
+
+  function renderTables(data) {
+    const dates = data.dates || [];
+    const metrics = state.metrics.filter((m) => META[m]);
+    if (!metrics.length) return '<div class="empty-hint"><p>请至少选择一个指标</p></div>';
+    return metrics.map((metric) => {
+      const head = `<tr><th class="ops-shop-col">店铺</th>${dates.map((d) => `<th class="ops-date-col">${shortDate(d)}</th>`).join('')}</tr>`;
+      const rows = data.groups.map((group) => {
+        const schoolRow = `<tr class="ops-school-row"><td class="ops-shop-cell" colspan="${dates.length + 1}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
+        const shopRows = group.shops.map((shop) => `<tr>
+          <td class="ops-shop-cell" title="${esc(shop.shopName)}">${esc(shop.shopName)}${
+            shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
+          }</td>
+          ${dates.map((d) => formatCell(shop, metric, d)).join('')}
+        </tr>`).join('');
+        return schoolRow + shopRows + subtotalRow(group, metric, dates);
+      }).join('');
+      const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
+      return `<section class="ops-block">
+        <div class="ops-block-head"><h2>${META[metric].label}</h2><span>${shopCount} 家门店 · ${dates.length} 天</span></div>
+        <div class="ops-table-wrap">
+          <table class="ops-table">
+            <thead>${head}</thead>
+            <tbody>${rows}</tbody>
+          </table>
+        </div>
+      </section>`;
+    }).join('');
+  }
+
+  function sparkline(shop, metric, dates) {
+    const values = dates.map((d) => {
+      const v = (shop.series[metric] || {})[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 metric = state.metrics[0];
+    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">
+                ${state.metrics.map((m) => `<div class="ops-card-metric"><span>${META[m].label}</span><b>${m === 'click_rate' ? fmtValue(avgRate(shop, dates), m) : fmtValue(shop.totals && shop.totals[m], m)}</b></div>`).join('')}
+              </div>
+              ${sparkline(shop, metric, dates)}
+            </article>`).join('')}
+        </div>
+      </section>`).join('')}</div>`;
+  }
+
+  function avgRate(shop, dates) {
+    let weight = 0;
+    let acc = 0;
+    for (const date of dates) {
+      const exposure = (shop.series.exposure || {})[date];
+      const rate = (shop.series.click_rate || {})[date];
+      if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
+        weight += exposure;
+        acc += rate * exposure;
+      }
+    }
+    return weight > 0 ? acc / weight : null;
+  }
+
+  // ---------- 上传 ----------
+
+  function openUpload() {
+    const modal = $('opsUploadModal');
+    if (!modal) return;
+    state.upload.file = null;
+    $('opsFileName').textContent = '';
+    $('opsUploadResult').innerHTML = '';
+    $('opsYear').value = String(new Date().getFullYear());
+    $('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(force) {
+    const file = state.upload.file;
+    if (!file) {
+      $('opsUploadResult').innerHTML = '<span class="ops-error">请先选择 .xlsx 文件</span>';
+      return;
+    }
+    if (!/\.xlsx$/i.test(file.name)) {
+      $('opsUploadResult').innerHTML = '<span class="ops-error">只支持 .xlsx 文件</span>';
+      return;
+    }
+    const result = $('opsUploadResult');
+    const year = Number($('opsYear').value) || new Date().getFullYear();
+    $('opsUploadStart').disabled = true;
+    result.innerHTML = '<span class="ops-muted">正在解析并入库…</span>';
+    try {
+      const base64 = await readFileBase64(file);
+      const { res, body } = await api('/api/ops/upload', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ fileName: file.name, contentBase64: base64, year, force: !!force }),
+      });
+      if (res.status === 409) {
+        result.innerHTML = `<span class="ops-error">该文件已导入过(uploadId ${esc(body && body.uploadId)})</span>
+          <button type="button" class="btn btn-ghost btn-small" id="opsForceUpload" style="margin-left:10px">仍然导入</button>`;
+        $('opsForceUpload').addEventListener('click', () => doUpload(true));
+        $('opsUploadStart').disabled = false;
+        return;
+      }
+      if (!res.ok) {
+        const detail = body && (body.detail ? `${body.error}:${body.detail}` : body.error);
+        result.innerHTML = `<span class="ops-error">${esc(detail || ('导入失败 HTTP ' + res.status))}</span>`;
+        $('opsUploadStart').disabled = false;
+        return;
+      }
+      const r = body.report || {};
+      const warn = (body.warnings || []).slice(0, 5).map((w) => `<li>第 ${w.row} 行 ${esc(w.column)}:${esc(w.raw || '空')}(${esc(w.reason)})</li>`).join('');
+      result.innerHTML = `<div class="ops-ok">
+        <b>导入完成</b>:${r.rows} 行 · ${r.shops} 家门店 · ${r.dates ? shortDate(r.dates.from) + ' ~ ' + shortDate(r.dates.to) : ''} · 覆盖 ${r.overwritten} 行 · 警告 ${r.warningCount} 条
+        ${warn ? `<ul class="ops-warn-list">${warn}</ul>` : ''}
+      </div>`;
+      state.from = '';
+      state.to = '';
+      state.schools = null;
+      await init(true);
+    } catch (e) {
+      result.innerHTML = `<span class="ops-error">${esc(e.message || e)}</span>`;
+      $('opsUploadStart').disabled = false;
+    }
+  }
+
+  // ---------- 导入记录 ----------
+
+  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 init(force) {
+    if (state.inited && !force) return;
+    state.inited = true;
+    await loadSummary();
+    renderBar();
+    renderHeaderTools();
+    renderBody();
+    if (state.summary && state.summary.enabled && state.summary.rowCount) await loadRows();
+  }
+
+  function bindOnce() {
+    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 file = $('opsFile');
+    if (file) {
+      file.addEventListener('change', () => {
+        state.upload.file = file.files && file.files[0] ? file.files[0] : null;
+        $('opsFileName').textContent = state.upload.file ? state.upload.file.name : '';
+      });
+    }
+    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) => {
+        const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+        if (!f) return;
+        state.upload.file = f;
+        $('opsFileName').textContent = f.name;
+      });
+      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);
+  };
+})();

+ 68 - 0
public/style.css

@@ -1180,3 +1180,71 @@ 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 tbody tr:hover td { background: #faf9f7; }
+.ops-table 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-field .ops-input { width: 120px; }
+.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; }

+ 67 - 0
scripts/db-export.js

@@ -0,0 +1,67 @@
+#!/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 summary = await store.summary();
+
+  const payload = {
+    version: 1,
+    exportedAt: new Date().toISOString(),
+    driver: store.kind,
+    withFiles,
+    uploads,
+    shops: summary.shops,
+    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} 行 → ${outPath}${withFiles ? '(含原件)' : '(不含原件)'}`);
+  await store.close();
+})();

+ 88 - 0
scripts/db-import.js

@@ -0,0 +1,88 @@
+#!/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'),
+        });
+      }
+      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);
+  }
+
+  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


+ 25 - 1
scripts/smoke.js

@@ -118,8 +118,16 @@ 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' },
     stdio: ['ignore', 'pipe', 'pipe'],
@@ -334,6 +342,22 @@ 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/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');

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

@@ -0,0 +1,346 @@
+#!/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 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 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 占位行未产生数据', () => {
+    expect(parsed.shops.every((s) => s.shopName && s.shopName.trim()), '存在空店铺名');
+  });
+
+  // ---------- 端到端(临时 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?metrics=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.metrics.length === 1 && r.body.metrics[0] === 'order_cnt', 'metrics 过滤失效');
+      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?metrics=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.trim().split('\n').length === 37, `行数 ${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 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);
+});

+ 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) {

+ 1 - 0
server/routes/index.js

@@ -10,6 +10,7 @@ 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('./agent.js').register(app, ctx);
   require('./static.js').register(app, ctx);
 }

+ 380 - 0
server/routes/ops.js

@@ -0,0 +1,380 @@
+'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 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 METRIC_KEYS = opsImport.METRIC_KEYS;
+const SUM_METRICS = new Set(['order_cnt', 'revenue', 'exposure']);
+
+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 parseMetricsParam(value) {
+  const wanted = splitList(value);
+  if (!wanted.length) return METRIC_KEYS.slice();
+  return METRIC_KEYS.filter((k) => wanted.includes(k));
+}
+
+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 buildMatrix(rows, dates) {
+  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 metric of METRIC_KEYS) {
+      if (!shop.series[metric]) shop.series[metric] = {};
+      shop.series[metric][row.date] = row[metric];
+    }
+    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 metric of METRIC_KEYS) {
+        if (!SUM_METRICS.has(metric)) continue;
+        let sum = 0;
+        let seen = false;
+        for (const date of dates) {
+          const value = shop.series[metric][date];
+          if (typeof value === 'number') { sum += value; seen = true; }
+        }
+        shop.totals[metric] = seen ? Math.round(sum * 100) / 100 : null;
+      }
+    }
+    groups.push({ school, shops });
+  }
+  return groups;
+}
+
+function csvCell(value) {
+  const text = value == null ? '' : String(value);
+  return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
+}
+
+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');
+    if (!body.force) {
+      const existing = await store.findUploadBySha(sha256);
+      if (existing) {
+        return sendJson(res, 409, { error: '该文件已导入过', uploadId: existing, duplicate: true });
+      }
+    }
+
+    const year = Number(body.year) || new Date().getFullYear();
+    let parsed;
+    try {
+      parsed = opsImport.parseWorkbook(content, { year });
+    } 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] || '';
+
+    // 覆盖计数:该区间内已存在的「日期 + 店铺」组合
+    let overwritten = 0;
+    try {
+      const existingRows = await store.queryRows({ from, to });
+      const known = new Set(existingRows.map((r) => `${r.date}|${r.shopKey}`));
+      for (const row of parsed.rows) if (known.has(`${row.date}|${row.shopKey}`)) overwritten += 1;
+    } catch { /* 计数失败不影响导入 */ }
+
+    const report = {
+      rows: parsed.rows.length,
+      shops: parsed.shops.length,
+      overwritten,
+      warningCount: parsed.warnings.length,
+      dates: { from, to },
+      year,
+      parser: { kind: 'manual-ops', version: PARSER_VERSION },
+      warnings: parsed.warnings.slice(0, 500),
+    };
+
+    try {
+      await store.withTransaction(async (tx) => {
+        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 })));
+      });
+    } catch (e) {
+      return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
+    }
+
+    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/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);
+    const metrics = parseMetricsParam(query.metrics);
+    let rows;
+    try {
+      rows = await store.queryRows({ from: from || undefined, to: to || undefined, schools, shops });
+    } catch (e) {
+      return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
+    }
+    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,
+      metrics,
+      groups: buildMatrix(rows, dates),
+    });
+  });
+
+  // ---------- 导入记录 ----------
+
+  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();
+    let parsed;
+    try {
+      parsed = opsImport.parseWorkbook(record.content, { year });
+    } 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] || '';
+    const report = {
+      rows: parsed.rows.length,
+      shops: parsed.shops.length,
+      overwritten: 0,
+      warningCount: parsed.warnings.length,
+      dates: { from, to },
+      year,
+      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.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);
+    const metrics = parseMetricsParam(query.metrics);
+    let rows;
+    try {
+      rows = await store.queryRows({ from: from || undefined, to: to || undefined, schools, shops });
+    } catch (e) {
+      return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
+    }
+    const lines = [];
+    if (metrics.length === 1) {
+      const metric = metrics[0];
+      const dates = [...new Set(rows.map((r) => r.date))].sort();
+      lines.push(['学校', '店铺ID', '店铺名称', '状态', ...dates].map(csvCell).join(','));
+      const byShop = new Map();
+      for (const row of rows) {
+        if (!byShop.has(row.shopKey)) byShop.set(row.shopKey, { row, values: {} });
+        byShop.get(row.shopKey).values[row.date] = row[metric];
+      }
+      for (const { row, values } of byShop.values()) {
+        lines.push([row.school, row.shopId || '', row.shopName, row.status || '',
+          ...dates.map((d) => (values[d] == null ? '' : values[d]))].map(csvCell).join(','));
+      }
+    } else {
+      lines.push(['学校', '店铺ID', '店铺名称', '日期', '指标', '数值'].map(csvCell).join(','));
+      for (const row of rows) {
+        for (const metric of metrics) {
+          const value = row[metric];
+          lines.push([row.school, row.shopId || '', row.shopName, row.date, metric, 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 };

+ 1 - 0
server/routes/static.js

@@ -16,6 +16,7 @@ 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'],
   '/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'],

+ 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 };

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

@@ -0,0 +1,307 @@
+'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)`,
+];
+
+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 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 summary() {
+    const poolRef = getPool();
+    const [uploadCount, rowCount, range, 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 MIN(date) AS f, MAX(date) AS t FROM ops_row'),
+      poolRef.query('SELECT school, COUNT(*)::int AS c FROM ops_shop GROUP BY school ORDER BY school ASC'),
+      poolRef.query('SELECT key, shop_id, shop_name, school, status FROM ops_shop 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,
+      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 close() {
+    if (pool) {
+      try { await pool.end(); } catch { /* ignore */ }
+      pool = null;
+      initPromise = null;
+    }
+  }
+
+  return {
+    kind: 'postgres',
+    init,
+    health,
+    withTransaction,
+    findUploadBySha,
+    listUploads,
+    getUpload,
+    getContent,
+    queryRows,
+    summary,
+    close,
+  };
+}
+
+module.exports = { createPgStore };

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

@@ -0,0 +1,331 @@
+'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)`,
+];
+
+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 });
+    const { DatabaseSync } = require('node:sqlite');
+    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);
+        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 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, deleteRowsByUpload, updateReport });
+      handle.exec('COMMIT');
+      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 range = handle.prepare('SELECT MIN(date) AS f, MAX(date) AS t FROM ops_row').get();
+    const schools = handle.prepare('SELECT school, COUNT(*) AS c FROM ops_shop GROUP BY school ORDER BY school ASC')
+      .all().map((r) => ({ name: r.school, shopCount: r.c }));
+    const shops = handle.prepare('SELECT key, shop_id, shop_name, school, status FROM ops_shop 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,
+      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 close() {
+    if (db) {
+      try { db.close(); } catch { /* ignore */ }
+      db = null;
+      initPromise = null;
+    }
+  }
+
+  return {
+    kind: 'sqlite',
+    file: dbFile,
+    init,
+    health,
+    withTransaction,
+    insertUpload,
+    upsertRows,
+    upsertShops,
+    deleteRowsByUpload,
+    updateReport,
+    findUploadBySha,
+    listUploads,
+    getUpload,
+    getContent,
+    queryRows,
+    summary,
+    close,
+  };
+}
+
+module.exports = { createSqliteStore };