ops.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. 'use strict';
  2. // 店铺运营数据:上传解析、矩阵查询、导入记录、原件下载、重新解析、CSV 导出。
  3. // 存储由 ops-store 决定(默认本地 SQLite),此处只做协议与编排。
  4. const crypto = require('crypto');
  5. const { sendJson, readBody } = require('../util.js');
  6. const opsImport = require('../../ops-import.js');
  7. const { createOpsStore } = require('../../ops-store.js');
  8. const { loadSchools } = require('../../schools-store.js');
  9. const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
  10. const UPLOAD_ID_RE = /^\d{8}-\d{6}-[a-z0-9]{6}$/;
  11. const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  12. const PARSER_VERSION = 1;
  13. const METRIC_KEYS = opsImport.METRIC_KEYS;
  14. const SUM_METRICS = new Set(['order_cnt', 'revenue', 'exposure']);
  15. function pad2(n) {
  16. return String(n).padStart(2, '0');
  17. }
  18. function newUploadId() {
  19. const d = new Date();
  20. const stamp = `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
  21. return `${stamp}-${crypto.randomBytes(3).toString('hex')}`;
  22. }
  23. function sanitizeName(name) {
  24. const text = String(name || 'upload.xlsx')
  25. .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
  26. .trim()
  27. .slice(0, 120);
  28. return text || 'upload.xlsx';
  29. }
  30. function splitList(value) {
  31. return String(value || '')
  32. .split(',')
  33. .map((s) => s.trim())
  34. .filter(Boolean);
  35. }
  36. function parseMetricsParam(value) {
  37. const wanted = splitList(value);
  38. if (!wanted.length) return METRIC_KEYS.slice();
  39. return METRIC_KEYS.filter((k) => wanted.includes(k));
  40. }
  41. function parseYmd(value) {
  42. const text = String(value || '').replace(/-/g, '');
  43. return /^\d{8}$/.test(text) ? text : '';
  44. }
  45. /** 存储不可用时的统一响应(summary 例外,它要返回 200 + enabled:false) */
  46. async function requireHealthy(store, res) {
  47. const health = await store.health();
  48. if (health.ok) return true;
  49. sendJson(res, 503, {
  50. error: '存储不可用',
  51. reason: health.reason || 'storage-unreachable',
  52. detail: health.detail || '',
  53. });
  54. return false;
  55. }
  56. function buildMatrix(rows, dates) {
  57. const schools = new Map();
  58. for (const row of rows) {
  59. const schoolName = row.school || '未分组';
  60. if (!schools.has(schoolName)) schools.set(schoolName, new Map());
  61. const shopMap = schools.get(schoolName);
  62. if (!shopMap.has(row.shopKey)) {
  63. shopMap.set(row.shopKey, {
  64. key: row.shopKey,
  65. shopId: row.shopId,
  66. shopName: row.shopName,
  67. status: row.status,
  68. series: {},
  69. quality: {},
  70. totals: {},
  71. });
  72. }
  73. const shop = shopMap.get(row.shopKey);
  74. for (const metric of METRIC_KEYS) {
  75. if (!shop.series[metric]) shop.series[metric] = {};
  76. shop.series[metric][row.date] = row[metric];
  77. }
  78. if (Array.isArray(row.quality) && row.quality.length) shop.quality[row.date] = row.quality;
  79. }
  80. const groups = [];
  81. for (const [school, shopMap] of [...schools.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh-Hans-CN'))) {
  82. const shops = [...shopMap.values()].sort((a, b) => String(a.shopName).localeCompare(String(b.shopName), 'zh-Hans-CN'));
  83. for (const shop of shops) {
  84. for (const metric of METRIC_KEYS) {
  85. if (!SUM_METRICS.has(metric)) continue;
  86. let sum = 0;
  87. let seen = false;
  88. for (const date of dates) {
  89. const value = shop.series[metric][date];
  90. if (typeof value === 'number') { sum += value; seen = true; }
  91. }
  92. shop.totals[metric] = seen ? Math.round(sum * 100) / 100 : null;
  93. }
  94. }
  95. groups.push({ school, shops });
  96. }
  97. return groups;
  98. }
  99. function numOrNull(value) {
  100. if (value === null || value === undefined || value === '') return null;
  101. const n = Number(value);
  102. return Number.isFinite(n) ? n : null;
  103. }
  104. /** 平台全字段行 → 矩阵行(只取 4 个可展示指标,其余字段留在 raw) */
  105. function platformRowToMatrix(row) {
  106. const d = row.data || {};
  107. return {
  108. date: row.date,
  109. shopKey: String(row.shopId || ''),
  110. shopId: String(row.shopId || ''),
  111. shopName: row.shopName || '',
  112. school: row.school || '',
  113. status: '',
  114. order_cnt: numOrNull(d['有效订单']),
  115. revenue: numOrNull(d['营业额']),
  116. exposure: numOrNull(d['曝光人数']),
  117. click_rate: numOrNull(d['进店转化率']),
  118. raw: d,
  119. quality: [],
  120. };
  121. }
  122. /** 人工表 + 平台明细合并:同一天同一店,人工优先,平台补位 */
  123. async function loadMergedRows(store, { from, to, schools, shops } = {}) {
  124. const manual = await store.queryRows({ from, to, schools, shops });
  125. let platform = [];
  126. try {
  127. platform = await store.queryPlatformRows({ from, to });
  128. } catch { /* 平台表不可用不影响人工数据 */ }
  129. const map = new Map();
  130. for (const row of manual) map.set(`${row.date}|${row.shopKey}`, row);
  131. for (const row of platform) {
  132. const key = `${row.date}|${row.shopId}`;
  133. if (map.has(key)) continue;
  134. map.set(key, platformRowToMatrix(row));
  135. }
  136. let rows = [...map.values()];
  137. if (schools && schools.length) rows = rows.filter((r) => schools.includes(String(r.school || '')));
  138. if (shops && shops.length) rows = rows.filter((r) => shops.includes(String(r.shopId || r.shopKey || '')));
  139. return rows;
  140. }
  141. function csvCell(value) {
  142. const text = value == null ? '' : String(value);
  143. return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
  144. }
  145. /** 从文件名识别已登记学校(取最长匹配,如「浙江工商桐庐校区20261018…」→ 浙江工商大学桐庐校区) */
  146. function resolveSchoolFromFileName(fileName) {
  147. let best = '';
  148. let bestName = '';
  149. for (const school of loadSchools()) {
  150. for (const candidate of [school.name, ...(school.aliases || [])]) {
  151. const name = String(candidate || '').trim();
  152. if (name && fileName.includes(name) && name.length > bestName.length) {
  153. best = school.name;
  154. bestName = name;
  155. }
  156. }
  157. }
  158. return best;
  159. }
  160. function register(app, ctx) {
  161. const store = createOpsStore(ctx.config);
  162. // ---------- 上传 ----------
  163. app.post('/api/ops/upload', async ({ req, res }) => {
  164. const body = await readBody(req);
  165. const fileName = sanitizeName(body.fileName);
  166. if (!/\.xlsx$/i.test(fileName)) {
  167. return sendJson(res, 400, { error: '只支持 .xlsx 文件' });
  168. }
  169. const base64 = String(body.contentBase64 || '').replace(/^data:[^;]+;base64,/, '');
  170. if (!base64) return sendJson(res, 400, { error: '缺少文件内容' });
  171. let content;
  172. try {
  173. content = Buffer.from(base64, 'base64');
  174. } catch {
  175. return sendJson(res, 400, { error: 'base64 解码失败' });
  176. }
  177. if (!content.length) return sendJson(res, 400, { error: '文件内容为空' });
  178. if (content.length > MAX_UPLOAD_BYTES) {
  179. return sendJson(res, 413, { error: '文件超过 10MB 上限' });
  180. }
  181. if (!(await requireHealthy(store, res))) return;
  182. const sha256 = crypto.createHash('sha256').update(content).digest('hex');
  183. const existing = await store.findUploadBySha(sha256);
  184. if (!body.force) {
  185. if (existing) {
  186. return sendJson(res, 409, { error: '该文件已导入过', uploadId: existing, duplicate: true });
  187. }
  188. }
  189. const year = Number(body.year) || new Date().getFullYear();
  190. let school = String(body.school || '').trim().slice(0, 60);
  191. let parsed;
  192. try {
  193. parsed = opsImport.parseAny(content, { year, school });
  194. } catch (e) {
  195. return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
  196. }
  197. if (!parsed.rows.length) {
  198. return sendJson(res, 400, { error: '未解析到有效数据行' });
  199. }
  200. const uploadId = newUploadId();
  201. const uploadedAt = new Date().toISOString();
  202. const from = parsed.dates[0] || '';
  203. const to = parsed.dates[parsed.dates.length - 1] || '';
  204. // ---------- 平台导出(全字段明细) ----------
  205. if (parsed.kind === 'platform-export') {
  206. // 表里没有学校列:优先用上传时选定的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」)
  207. if (!school) school = resolveSchoolFromFileName(fileName);
  208. if (!school) {
  209. return sendJson(res, 400, { error: '请选择学校(平台导出表里没有学校列)', code: 'OPS_SCHOOL_REQUIRED' });
  210. }
  211. const existingRows = await store.queryPlatformRows({ from, to });
  212. const known = new Map(existingRows.map((r) => [`${r.date}|${r.shopId}`, r]));
  213. let newRows = 0;
  214. let identicalRows = 0;
  215. let conflictRows = 0;
  216. const incoming = [];
  217. for (const row of parsed.rows) {
  218. const key = `${row.date}|${row.shopId}`;
  219. const old = known.get(key);
  220. if (!old) { newRows += 1; incoming.push(row); continue; }
  221. const sameData = JSON.stringify(old.data) === JSON.stringify(row.data);
  222. if (sameData && old.school === school) { identicalRows += 1; continue; }
  223. conflictRows += 1;
  224. if (body.force) incoming.push(row);
  225. }
  226. const toWrite = body.force ? parsed.rows : incoming;
  227. const report = {
  228. kind: 'platform-export',
  229. rows: parsed.rows.length,
  230. shops: parsed.shops.length,
  231. newRows,
  232. identicalRows,
  233. conflictRows,
  234. writtenRows: toWrite.length,
  235. warningCount: parsed.warnings.length,
  236. dates: { from, to },
  237. year,
  238. schoolOverride: school,
  239. parser: { kind: 'platform-export', version: PARSER_VERSION },
  240. warnings: parsed.warnings.slice(0, 500),
  241. };
  242. try {
  243. await store.withTransaction(async (tx) => {
  244. if (body.force) await tx.deletePlatformRowsBySha(sha256);
  245. await tx.insertUpload({
  246. id: uploadId,
  247. originalName: fileName,
  248. sha256,
  249. sizeBytes: content.length,
  250. uploadedAt,
  251. year,
  252. parserKind: 'platform-export',
  253. parserVersion: PARSER_VERSION,
  254. report,
  255. content,
  256. });
  257. if (toWrite.length) {
  258. await tx.upsertPlatformRows(toWrite.map((row) => ({ ...row, school, uploadId })));
  259. }
  260. });
  261. } catch (e) {
  262. return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
  263. }
  264. return sendJson(res, 200, {
  265. ok: true,
  266. uploadId,
  267. report,
  268. warnings: parsed.warnings.slice(0, 200),
  269. duplicateData: report.newRows === 0 && report.conflictRows === 0,
  270. });
  271. }
  272. // ---------- 人工统计表 ----------
  273. // 表格里没有「学校名称」列、上传时也没指定 → 让用户补选,避免落成空白分组
  274. if (!parsed.shops.some((shop) => shop.school && shop.school.trim())) {
  275. return sendJson(res, 400, {
  276. error: '表格里没有「学校名称」列,请在上传时选择或填写学校',
  277. code: 'OPS_SCHOOL_REQUIRED',
  278. });
  279. }
  280. // 覆盖计数:该区间内已存在的「日期 + 店铺」组合
  281. let overwritten = 0;
  282. try {
  283. const existingDaily = await store.queryRows({ from, to });
  284. const knownDaily = new Set(existingDaily.map((r) => `${r.date}|${r.shopKey}`));
  285. for (const row of parsed.rows) if (knownDaily.has(`${row.date}|${row.shopKey}`)) overwritten += 1;
  286. } catch { /* 计数失败不影响导入 */ }
  287. const report = {
  288. kind: 'manual-ops',
  289. rows: parsed.rows.length,
  290. shops: parsed.shops.length,
  291. overwritten,
  292. warningCount: parsed.warnings.length,
  293. dates: { from, to },
  294. year,
  295. schoolOverride: school,
  296. parser: { kind: 'manual-ops', version: PARSER_VERSION },
  297. warnings: parsed.warnings.slice(0, 500),
  298. };
  299. try {
  300. await store.withTransaction(async (tx) => {
  301. if (body.force) await tx.deleteRowsBySha(sha256);
  302. await tx.insertUpload({
  303. id: uploadId,
  304. originalName: fileName,
  305. sha256,
  306. sizeBytes: content.length,
  307. uploadedAt,
  308. year,
  309. parserKind: 'manual-ops',
  310. parserVersion: PARSER_VERSION,
  311. report,
  312. content,
  313. });
  314. await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId })));
  315. await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: uploadId })));
  316. await tx.pruneOrphanShops();
  317. });
  318. } catch (e) {
  319. return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
  320. }
  321. sendJson(res, 200, {
  322. ok: true,
  323. uploadId,
  324. report,
  325. warnings: parsed.warnings.slice(0, 200),
  326. });
  327. });
  328. // ---------- 概览 ----------
  329. app.get('/api/ops/summary', async ({ res }) => {
  330. const health = await store.health();
  331. if (!health.ok) {
  332. return sendJson(res, 200, {
  333. enabled: false,
  334. driver: store.kind,
  335. reason: health.reason || 'storage-unreachable',
  336. detail: health.detail || '',
  337. });
  338. }
  339. try {
  340. const data = await store.summary();
  341. sendJson(res, 200, { enabled: true, driver: store.kind, ...data });
  342. } catch (e) {
  343. sendJson(res, 200, {
  344. enabled: false,
  345. driver: store.kind,
  346. reason: 'storage-unreachable',
  347. detail: e.message || String(e),
  348. });
  349. }
  350. });
  351. // ---------- 矩阵查询 ----------
  352. app.get('/api/ops/rows', async ({ res, query }) => {
  353. if (!(await requireHealthy(store, res))) return;
  354. const from = parseYmd(query.from);
  355. const to = parseYmd(query.to);
  356. const schools = splitList(query.schools);
  357. const shops = splitList(query.shops);
  358. const metrics = parseMetricsParam(query.metrics);
  359. let rows;
  360. try {
  361. rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
  362. } catch (e) {
  363. return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
  364. }
  365. const dates = [...new Set(rows.map((r) => r.date))].sort();
  366. sendJson(res, 200, {
  367. from: from || (dates[0] || null),
  368. to: to || (dates[dates.length - 1] || null),
  369. dates,
  370. metrics,
  371. groups: buildMatrix(rows, dates),
  372. });
  373. });
  374. // ---------- 导入记录 ----------
  375. app.get('/api/ops/uploads', async ({ res }) => {
  376. if (!(await requireHealthy(store, res))) return;
  377. try {
  378. const uploads = await store.listUploads();
  379. sendJson(res, 200, { uploads });
  380. } catch (e) {
  381. sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
  382. }
  383. });
  384. // ---------- 原件下载 ----------
  385. app.get('/api/ops/uploads/:id/file', async ({ res, params }) => {
  386. if (!UPLOAD_ID_RE.test(params.id)) return sendJson(res, 400, { error: '非法的上传 ID' });
  387. if (!(await requireHealthy(store, res))) return;
  388. const record = await store.getContent(params.id);
  389. if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
  390. const name = sanitizeName(record.originalName);
  391. res.writeHead(200, {
  392. 'Content-Type': XLSX_MIME,
  393. 'Content-Length': record.content.length,
  394. 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,
  395. });
  396. res.end(record.content);
  397. });
  398. // ---------- 重新解析 ----------
  399. app.post('/api/ops/uploads/:id/reparse', async ({ res, params }) => {
  400. if (!UPLOAD_ID_RE.test(params.id)) return sendJson(res, 400, { error: '非法的上传 ID' });
  401. if (!(await requireHealthy(store, res))) return;
  402. const record = await store.getContent(params.id);
  403. if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
  404. const upload = await store.getUpload(params.id);
  405. const year = (upload && upload.year) || new Date().getFullYear();
  406. const school = (upload && upload.report && upload.report.schoolOverride) || '';
  407. let parsed;
  408. try {
  409. parsed = opsImport.parseAny(record.content, { year, school });
  410. } catch (e) {
  411. return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
  412. }
  413. const from = parsed.dates[0] || '';
  414. const to = parsed.dates[parsed.dates.length - 1] || '';
  415. if (parsed.kind === 'platform-export') {
  416. const report = {
  417. kind: 'platform-export',
  418. rows: parsed.rows.length,
  419. shops: parsed.shops.length,
  420. newRows: parsed.rows.length,
  421. identicalRows: 0,
  422. conflictRows: 0,
  423. writtenRows: parsed.rows.length,
  424. warningCount: parsed.warnings.length,
  425. dates: { from, to },
  426. year,
  427. schoolOverride: school,
  428. parser: { kind: 'platform-export', version: PARSER_VERSION },
  429. warnings: parsed.warnings.slice(0, 500),
  430. reparsedAt: new Date().toISOString(),
  431. };
  432. try {
  433. await store.withTransaction(async (tx) => {
  434. await tx.deletePlatformRowsByUpload(params.id);
  435. await tx.upsertPlatformRows(parsed.rows.map((row) => ({ ...row, school, uploadId: params.id })));
  436. await tx.updateReport(params.id, report);
  437. });
  438. } catch (e) {
  439. return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
  440. }
  441. return sendJson(res, 200, { ok: true, uploadId: params.id, report });
  442. }
  443. const report = {
  444. kind: 'manual-ops',
  445. rows: parsed.rows.length,
  446. shops: parsed.shops.length,
  447. overwritten: 0,
  448. warningCount: parsed.warnings.length,
  449. dates: { from, to },
  450. year,
  451. schoolOverride: school,
  452. parser: { kind: 'manual-ops', version: PARSER_VERSION },
  453. warnings: parsed.warnings.slice(0, 500),
  454. reparsedAt: new Date().toISOString(),
  455. };
  456. try {
  457. await store.withTransaction(async (tx) => {
  458. await tx.deleteRowsByUpload(params.id);
  459. await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId: params.id })));
  460. await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: params.id })));
  461. await tx.pruneOrphanShops();
  462. await tx.updateReport(params.id, report);
  463. });
  464. } catch (e) {
  465. return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
  466. }
  467. sendJson(res, 200, { ok: true, uploadId: params.id, report });
  468. });
  469. // ---------- 导出 ----------
  470. app.get('/api/ops/export', async ({ res, query }) => {
  471. if (!(await requireHealthy(store, res))) return;
  472. const from = parseYmd(query.from);
  473. const to = parseYmd(query.to);
  474. const schools = splitList(query.schools);
  475. const shops = splitList(query.shops);
  476. const metrics = parseMetricsParam(query.metrics);
  477. let rows;
  478. try {
  479. rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
  480. } catch (e) {
  481. return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
  482. }
  483. const lines = [];
  484. if (metrics.length === 1) {
  485. const metric = metrics[0];
  486. const dates = [...new Set(rows.map((r) => r.date))].sort();
  487. lines.push(['学校', '店铺ID', '店铺名称', '状态', ...dates].map(csvCell).join(','));
  488. const byShop = new Map();
  489. for (const row of rows) {
  490. if (!byShop.has(row.shopKey)) byShop.set(row.shopKey, { row, values: {} });
  491. byShop.get(row.shopKey).values[row.date] = row[metric];
  492. }
  493. for (const { row, values } of byShop.values()) {
  494. lines.push([row.school, row.shopId || '', row.shopName, row.status || '',
  495. ...dates.map((d) => (values[d] == null ? '' : values[d]))].map(csvCell).join(','));
  496. }
  497. } else {
  498. lines.push(['学校', '店铺ID', '店铺名称', '日期', '指标', '数值'].map(csvCell).join(','));
  499. for (const row of rows) {
  500. for (const metric of metrics) {
  501. const value = row[metric];
  502. lines.push([row.school, row.shopId || '', row.shopName, row.date, metric, value == null ? '' : value]
  503. .map(csvCell).join(','));
  504. }
  505. }
  506. }
  507. const csv = '\ufeff' + lines.join('\r\n');
  508. res.writeHead(200, {
  509. 'Content-Type': 'text/csv; charset=utf-8',
  510. 'Content-Disposition': `attachment; filename="ops-${from || 'all'}-${to || 'all'}.csv"`,
  511. });
  512. res.end(csv);
  513. });
  514. }
  515. module.exports = { register };