Explorar el Código

docs: 店铺运营数据上传设计(云端 PG 存储 + 矩阵/卡片视图)

liangzhao hace 2 semanas
padre
commit
11e730a3bd
Se han modificado 1 ficheros con 550 adiciones y 0 borrados
  1. 550 0
      docs/superpowers/specs/2026-10-20-manual-ops-import-design.md

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

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