ops.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  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 csvCell(value) {
  100. const text = value == null ? '' : String(value);
  101. return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
  102. }
  103. /** 从文件名识别已登记学校(取最长匹配,如「浙江工商桐庐校区20261018…」→ 浙江工商大学桐庐校区) */
  104. function resolveSchoolFromFileName(fileName) {
  105. let best = '';
  106. let bestName = '';
  107. for (const school of loadSchools()) {
  108. for (const candidate of [school.name, ...(school.aliases || [])]) {
  109. const name = String(candidate || '').trim();
  110. if (name && fileName.includes(name) && name.length > bestName.length) {
  111. best = school.name;
  112. bestName = name;
  113. }
  114. }
  115. }
  116. return best;
  117. }
  118. function register(app, ctx) {
  119. const store = createOpsStore(ctx.config);
  120. // ---------- 上传 ----------
  121. app.post('/api/ops/upload', async ({ req, res }) => {
  122. const body = await readBody(req);
  123. const fileName = sanitizeName(body.fileName);
  124. if (!/\.xlsx$/i.test(fileName)) {
  125. return sendJson(res, 400, { error: '只支持 .xlsx 文件' });
  126. }
  127. const base64 = String(body.contentBase64 || '').replace(/^data:[^;]+;base64,/, '');
  128. if (!base64) return sendJson(res, 400, { error: '缺少文件内容' });
  129. let content;
  130. try {
  131. content = Buffer.from(base64, 'base64');
  132. } catch {
  133. return sendJson(res, 400, { error: 'base64 解码失败' });
  134. }
  135. if (!content.length) return sendJson(res, 400, { error: '文件内容为空' });
  136. if (content.length > MAX_UPLOAD_BYTES) {
  137. return sendJson(res, 413, { error: '文件超过 10MB 上限' });
  138. }
  139. if (!(await requireHealthy(store, res))) return;
  140. const sha256 = crypto.createHash('sha256').update(content).digest('hex');
  141. const existing = await store.findUploadBySha(sha256);
  142. if (!body.force) {
  143. if (existing) {
  144. return sendJson(res, 409, { error: '该文件已导入过', uploadId: existing, duplicate: true });
  145. }
  146. }
  147. const year = Number(body.year) || new Date().getFullYear();
  148. let school = String(body.school || '').trim().slice(0, 60);
  149. let parsed;
  150. try {
  151. parsed = opsImport.parseAny(content, { year, school });
  152. } catch (e) {
  153. return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
  154. }
  155. if (!parsed.rows.length) {
  156. return sendJson(res, 400, { error: '未解析到有效数据行' });
  157. }
  158. const uploadId = newUploadId();
  159. const uploadedAt = new Date().toISOString();
  160. const from = parsed.dates[0] || '';
  161. const to = parsed.dates[parsed.dates.length - 1] || '';
  162. // ---------- 平台导出(全字段明细) ----------
  163. if (parsed.kind === 'platform-export') {
  164. // 表里没有学校列:优先用上传时选定的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」)
  165. if (!school) school = resolveSchoolFromFileName(fileName);
  166. if (!school) {
  167. return sendJson(res, 400, { error: '请选择学校(平台导出表里没有学校列)', code: 'OPS_SCHOOL_REQUIRED' });
  168. }
  169. const existingRows = await store.queryPlatformRows({ from, to });
  170. const known = new Map(existingRows.map((r) => [`${r.date}|${r.shopId}`, r]));
  171. let newRows = 0;
  172. let identicalRows = 0;
  173. let conflictRows = 0;
  174. const incoming = [];
  175. for (const row of parsed.rows) {
  176. const key = `${row.date}|${row.shopId}`;
  177. const old = known.get(key);
  178. if (!old) { newRows += 1; incoming.push(row); continue; }
  179. const sameData = JSON.stringify(old.data) === JSON.stringify(row.data);
  180. if (sameData && old.school === school) { identicalRows += 1; continue; }
  181. conflictRows += 1;
  182. if (body.force) incoming.push(row);
  183. }
  184. const toWrite = body.force ? parsed.rows : incoming;
  185. const report = {
  186. kind: 'platform-export',
  187. rows: parsed.rows.length,
  188. shops: parsed.shops.length,
  189. newRows,
  190. identicalRows,
  191. conflictRows,
  192. writtenRows: toWrite.length,
  193. warningCount: parsed.warnings.length,
  194. dates: { from, to },
  195. year,
  196. schoolOverride: school,
  197. parser: { kind: 'platform-export', version: PARSER_VERSION },
  198. warnings: parsed.warnings.slice(0, 500),
  199. };
  200. try {
  201. await store.withTransaction(async (tx) => {
  202. if (body.force) await tx.deletePlatformRowsBySha(sha256);
  203. await tx.insertUpload({
  204. id: uploadId,
  205. originalName: fileName,
  206. sha256,
  207. sizeBytes: content.length,
  208. uploadedAt,
  209. year,
  210. parserKind: 'platform-export',
  211. parserVersion: PARSER_VERSION,
  212. report,
  213. content,
  214. });
  215. if (toWrite.length) {
  216. await tx.upsertPlatformRows(toWrite.map((row) => ({ ...row, school, uploadId })));
  217. }
  218. });
  219. } catch (e) {
  220. return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
  221. }
  222. return sendJson(res, 200, {
  223. ok: true,
  224. uploadId,
  225. report,
  226. warnings: parsed.warnings.slice(0, 200),
  227. duplicateData: report.newRows === 0 && report.conflictRows === 0,
  228. });
  229. }
  230. // ---------- 人工统计表 ----------
  231. // 表格里没有「学校名称」列、上传时也没指定 → 让用户补选,避免落成空白分组
  232. if (!parsed.shops.some((shop) => shop.school && shop.school.trim())) {
  233. return sendJson(res, 400, {
  234. error: '表格里没有「学校名称」列,请在上传时选择或填写学校',
  235. code: 'OPS_SCHOOL_REQUIRED',
  236. });
  237. }
  238. // 覆盖计数:该区间内已存在的「日期 + 店铺」组合
  239. let overwritten = 0;
  240. try {
  241. const existingDaily = await store.queryRows({ from, to });
  242. const knownDaily = new Set(existingDaily.map((r) => `${r.date}|${r.shopKey}`));
  243. for (const row of parsed.rows) if (knownDaily.has(`${row.date}|${row.shopKey}`)) overwritten += 1;
  244. } catch { /* 计数失败不影响导入 */ }
  245. const report = {
  246. kind: 'manual-ops',
  247. rows: parsed.rows.length,
  248. shops: parsed.shops.length,
  249. overwritten,
  250. warningCount: parsed.warnings.length,
  251. dates: { from, to },
  252. year,
  253. schoolOverride: school,
  254. parser: { kind: 'manual-ops', version: PARSER_VERSION },
  255. warnings: parsed.warnings.slice(0, 500),
  256. };
  257. try {
  258. await store.withTransaction(async (tx) => {
  259. if (body.force) await tx.deleteRowsBySha(sha256);
  260. await tx.insertUpload({
  261. id: uploadId,
  262. originalName: fileName,
  263. sha256,
  264. sizeBytes: content.length,
  265. uploadedAt,
  266. year,
  267. parserKind: 'manual-ops',
  268. parserVersion: PARSER_VERSION,
  269. report,
  270. content,
  271. });
  272. await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId })));
  273. await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: uploadId })));
  274. await tx.pruneOrphanShops();
  275. });
  276. } catch (e) {
  277. return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
  278. }
  279. sendJson(res, 200, {
  280. ok: true,
  281. uploadId,
  282. report,
  283. warnings: parsed.warnings.slice(0, 200),
  284. });
  285. });
  286. // ---------- 概览 ----------
  287. app.get('/api/ops/summary', async ({ res }) => {
  288. const health = await store.health();
  289. if (!health.ok) {
  290. return sendJson(res, 200, {
  291. enabled: false,
  292. driver: store.kind,
  293. reason: health.reason || 'storage-unreachable',
  294. detail: health.detail || '',
  295. });
  296. }
  297. try {
  298. const data = await store.summary();
  299. sendJson(res, 200, { enabled: true, driver: store.kind, ...data });
  300. } catch (e) {
  301. sendJson(res, 200, {
  302. enabled: false,
  303. driver: store.kind,
  304. reason: 'storage-unreachable',
  305. detail: e.message || String(e),
  306. });
  307. }
  308. });
  309. // ---------- 矩阵查询 ----------
  310. app.get('/api/ops/rows', async ({ res, query }) => {
  311. if (!(await requireHealthy(store, res))) return;
  312. const from = parseYmd(query.from);
  313. const to = parseYmd(query.to);
  314. const schools = splitList(query.schools);
  315. const shops = splitList(query.shops);
  316. const metrics = parseMetricsParam(query.metrics);
  317. let rows;
  318. try {
  319. rows = await store.queryRows({ from: from || undefined, to: to || undefined, schools, shops });
  320. } catch (e) {
  321. return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
  322. }
  323. const dates = [...new Set(rows.map((r) => r.date))].sort();
  324. sendJson(res, 200, {
  325. from: from || (dates[0] || null),
  326. to: to || (dates[dates.length - 1] || null),
  327. dates,
  328. metrics,
  329. groups: buildMatrix(rows, dates),
  330. });
  331. });
  332. // ---------- 导入记录 ----------
  333. app.get('/api/ops/uploads', async ({ res }) => {
  334. if (!(await requireHealthy(store, res))) return;
  335. try {
  336. const uploads = await store.listUploads();
  337. sendJson(res, 200, { uploads });
  338. } catch (e) {
  339. sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
  340. }
  341. });
  342. // ---------- 原件下载 ----------
  343. app.get('/api/ops/uploads/:id/file', async ({ res, params }) => {
  344. if (!UPLOAD_ID_RE.test(params.id)) return sendJson(res, 400, { error: '非法的上传 ID' });
  345. if (!(await requireHealthy(store, res))) return;
  346. const record = await store.getContent(params.id);
  347. if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
  348. const name = sanitizeName(record.originalName);
  349. res.writeHead(200, {
  350. 'Content-Type': XLSX_MIME,
  351. 'Content-Length': record.content.length,
  352. 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(name)}`,
  353. });
  354. res.end(record.content);
  355. });
  356. // ---------- 重新解析 ----------
  357. app.post('/api/ops/uploads/:id/reparse', async ({ res, params }) => {
  358. if (!UPLOAD_ID_RE.test(params.id)) return sendJson(res, 400, { error: '非法的上传 ID' });
  359. if (!(await requireHealthy(store, res))) return;
  360. const record = await store.getContent(params.id);
  361. if (!record) return sendJson(res, 404, { error: '找不到该上传记录' });
  362. const upload = await store.getUpload(params.id);
  363. const year = (upload && upload.year) || new Date().getFullYear();
  364. const school = (upload && upload.report && upload.report.schoolOverride) || '';
  365. let parsed;
  366. try {
  367. parsed = opsImport.parseAny(record.content, { year, school });
  368. } catch (e) {
  369. return sendJson(res, 400, { error: e.message || '解析失败', code: e.code || 'OPS_PARSE_FAILED' });
  370. }
  371. const from = parsed.dates[0] || '';
  372. const to = parsed.dates[parsed.dates.length - 1] || '';
  373. if (parsed.kind === 'platform-export') {
  374. const report = {
  375. kind: 'platform-export',
  376. rows: parsed.rows.length,
  377. shops: parsed.shops.length,
  378. newRows: parsed.rows.length,
  379. identicalRows: 0,
  380. conflictRows: 0,
  381. writtenRows: parsed.rows.length,
  382. warningCount: parsed.warnings.length,
  383. dates: { from, to },
  384. year,
  385. schoolOverride: school,
  386. parser: { kind: 'platform-export', version: PARSER_VERSION },
  387. warnings: parsed.warnings.slice(0, 500),
  388. reparsedAt: new Date().toISOString(),
  389. };
  390. try {
  391. await store.withTransaction(async (tx) => {
  392. await tx.deletePlatformRowsByUpload(params.id);
  393. await tx.upsertPlatformRows(parsed.rows.map((row) => ({ ...row, school, uploadId: params.id })));
  394. await tx.updateReport(params.id, report);
  395. });
  396. } catch (e) {
  397. return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
  398. }
  399. return sendJson(res, 200, { ok: true, uploadId: params.id, report });
  400. }
  401. const report = {
  402. kind: 'manual-ops',
  403. rows: parsed.rows.length,
  404. shops: parsed.shops.length,
  405. overwritten: 0,
  406. warningCount: parsed.warnings.length,
  407. dates: { from, to },
  408. year,
  409. schoolOverride: school,
  410. parser: { kind: 'manual-ops', version: PARSER_VERSION },
  411. warnings: parsed.warnings.slice(0, 500),
  412. reparsedAt: new Date().toISOString(),
  413. };
  414. try {
  415. await store.withTransaction(async (tx) => {
  416. await tx.deleteRowsByUpload(params.id);
  417. await tx.upsertRows(parsed.rows.map((row) => ({ ...row, uploadId: params.id })));
  418. await tx.upsertShops(parsed.shops.map((shop) => ({ ...shop, lastUploadId: params.id })));
  419. await tx.pruneOrphanShops();
  420. await tx.updateReport(params.id, report);
  421. });
  422. } catch (e) {
  423. return sendJson(res, 500, { error: '重新解析写入失败', detail: e.message || String(e) });
  424. }
  425. sendJson(res, 200, { ok: true, uploadId: params.id, report });
  426. });
  427. // ---------- 导出 ----------
  428. app.get('/api/ops/export', async ({ res, query }) => {
  429. if (!(await requireHealthy(store, res))) return;
  430. const from = parseYmd(query.from);
  431. const to = parseYmd(query.to);
  432. const schools = splitList(query.schools);
  433. const shops = splitList(query.shops);
  434. const metrics = parseMetricsParam(query.metrics);
  435. let rows;
  436. try {
  437. rows = await store.queryRows({ from: from || undefined, to: to || undefined, schools, shops });
  438. } catch (e) {
  439. return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
  440. }
  441. const lines = [];
  442. if (metrics.length === 1) {
  443. const metric = metrics[0];
  444. const dates = [...new Set(rows.map((r) => r.date))].sort();
  445. lines.push(['学校', '店铺ID', '店铺名称', '状态', ...dates].map(csvCell).join(','));
  446. const byShop = new Map();
  447. for (const row of rows) {
  448. if (!byShop.has(row.shopKey)) byShop.set(row.shopKey, { row, values: {} });
  449. byShop.get(row.shopKey).values[row.date] = row[metric];
  450. }
  451. for (const { row, values } of byShop.values()) {
  452. lines.push([row.school, row.shopId || '', row.shopName, row.status || '',
  453. ...dates.map((d) => (values[d] == null ? '' : values[d]))].map(csvCell).join(','));
  454. }
  455. } else {
  456. lines.push(['学校', '店铺ID', '店铺名称', '日期', '指标', '数值'].map(csvCell).join(','));
  457. for (const row of rows) {
  458. for (const metric of metrics) {
  459. const value = row[metric];
  460. lines.push([row.school, row.shopId || '', row.shopName, row.date, metric, value == null ? '' : value]
  461. .map(csvCell).join(','));
  462. }
  463. }
  464. }
  465. const csv = '\ufeff' + lines.join('\r\n');
  466. res.writeHead(200, {
  467. 'Content-Type': 'text/csv; charset=utf-8',
  468. 'Content-Disposition': `attachment; filename="ops-${from || 'all'}-${to || 'all'}.csv"`,
  469. });
  470. res.end(csv);
  471. });
  472. }
  473. module.exports = { register };