ops-import.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. 'use strict';
  2. /**
  3. * 店铺运营数据 · 解析层
  4. * ----------------------------------------------------------------------------
  5. * 把上传的 xlsx 识别成「人工统计横表」并转成规范行:
  6. * 一行一店 × 29 天 × 4 指标 → 长表(每店每天一行)
  7. *
  8. * 解析器以注册表组织:以后加「平台导出 124 列」只需追加一个 { kind, detect, parse },
  9. * 存储与界面不用改。
  10. */
  11. const { readXlsx } = require('./server/services/xlsx.js');
  12. const METRICS = {
  13. order_cnt: { label: '订单量', kind: 'int' },
  14. revenue: { label: '营收', kind: 'money' },
  15. exposure: { label: '曝光量', kind: 'int' },
  16. click_rate: { label: '进店转换率', kind: 'rate' },
  17. };
  18. const METRIC_KEYS = Object.keys(METRICS);
  19. // 表头里可能出现的指标写法(人工表用「转换率」,平台用「转化率」)
  20. const METRIC_BY_LABEL = {
  21. '订单量': 'order_cnt',
  22. '营收': 'revenue',
  23. '营业额': 'revenue',
  24. '曝光量': 'exposure',
  25. '进店转换率': 'click_rate',
  26. '进店转化率': 'click_rate',
  27. '转换率': 'click_rate',
  28. '转化率': 'click_rate',
  29. };
  30. const BASE_COLUMNS = {
  31. school: ['学校名称', '学校', '校区'],
  32. shopName: ['店铺名称', '门店名称', '店铺', '门店'],
  33. shopId: ['ID', '门店ID', '店铺ID'],
  34. status: ['上线状态', '状态'],
  35. };
  36. const DATE_COLUMN_RE = /^(\d{1,2})\.(\d{1,2})(订单量|营收|营业额|曝光量|进店转换率|进店转化率|转换率|转化率)$/;
  37. function parseError(message, code = 'OPS_PARSE_FAILED') {
  38. const err = new Error(message);
  39. err.code = code;
  40. return err;
  41. }
  42. function normalizeHeader(value) {
  43. return String(value == null ? '' : value).replace(/[\s\u3000]+/g, '').trim();
  44. }
  45. function findColumn(header, names) {
  46. for (let i = 0; i < header.length; i++) {
  47. const text = normalizeHeader(header[i]);
  48. if (names.includes(text)) return i;
  49. }
  50. // 兜底:包含匹配(例如「门店名称(必填)」)
  51. for (let i = 0; i < header.length; i++) {
  52. const text = normalizeHeader(header[i]);
  53. if (names.some((n) => text.includes(n))) return i;
  54. }
  55. return -1;
  56. }
  57. function pad2(n) {
  58. return String(n).padStart(2, '0');
  59. }
  60. function toShopId(raw) {
  61. if (raw == null || raw === '') return '';
  62. if (typeof raw === 'number') return Number.isInteger(raw) ? String(raw) : String(raw).replace(/\.0+$/, '');
  63. return String(raw).trim();
  64. }
  65. // ---------- 值清洗 ----------
  66. /**
  67. * 单元格 → 数值 + 质量标记
  68. * @returns {{ value: number|null, reasons: string[], rawText: string }}
  69. */
  70. function cleanValue(raw, metricKey) {
  71. const kind = METRICS[metricKey].kind;
  72. if (raw === null || raw === undefined || raw === '') {
  73. return { value: null, reasons: ['missing'], rawText: '' };
  74. }
  75. const reasons = [];
  76. let text = raw;
  77. if (typeof text === 'string') {
  78. text = text.trim();
  79. if (text === '') return { value: null, reasons: ['missing'], rawText: String(raw) };
  80. if (/[%%]/.test(text)) {
  81. text = text.replace(/[%%]/g, '');
  82. reasons.push('pct-symbol');
  83. }
  84. if (/\d\.\.\d/.test(text)) {
  85. text = text.replace(/\.{2,}/g, '.');
  86. reasons.push('malformed-percent');
  87. }
  88. }
  89. const num = typeof text === 'number' ? text : Number(text);
  90. if (!Number.isFinite(num)) {
  91. return { value: null, reasons: [...reasons, 'non-numeric'], rawText: String(raw) };
  92. }
  93. if (kind === 'rate') {
  94. const percentLike = reasons.includes('pct-symbol') || reasons.includes('malformed-percent');
  95. let value = num;
  96. if (percentLike) {
  97. value = num / 100;
  98. } else if (value > 1) {
  99. value = value / 100;
  100. reasons.push('percent-scaled');
  101. }
  102. if (value > 1 || value < 0) reasons.push('out-of-range');
  103. return { value: Math.round(value * 1e6) / 1e6, reasons, rawText: String(raw) };
  104. }
  105. if (kind === 'int') {
  106. let value = Math.round(num);
  107. if (value < 0) {
  108. value = 0;
  109. reasons.push('negative');
  110. }
  111. return { value, reasons, rawText: String(raw) };
  112. }
  113. // money
  114. if (num < 0) reasons.push('negative');
  115. return { value: Math.round(num * 100) / 100, reasons, rawText: String(raw) };
  116. }
  117. // ---------- 表头 ----------
  118. function detect(cells) {
  119. const header = (cells && cells[0]) || [];
  120. const texts = header.map(normalizeHeader);
  121. const metricHits = ['订单量', '营收', '营业额', '曝光量'].filter((label) => texts.some((t) => t.includes(label))).length;
  122. const hasRate = texts.some((t) => t.includes('转换率') || t.includes('转化率'));
  123. const hasDateCol = texts.some((t) => DATE_COLUMN_RE.test(t));
  124. return metricHits >= 2 && hasRate && hasDateCol ? 'manual-ops' : null;
  125. }
  126. /** 平台导出(全字段明细):一行一店一天,含「日期 / 门店编号 / 门店名称」+ 100 多列指标 */
  127. function detectPlatformExport(cells) {
  128. const header = ((cells && cells[0]) || []).map(normalizeHeader);
  129. if (header.length < 50) return false;
  130. const has = (name) => header.some((h) => h === name);
  131. return has('日期') && has('门店编号') && has('门店名称');
  132. }
  133. /** 日期归一化:2026-10-18 / 2026/10/18 / 20261018 → 20261018 */
  134. function normalizeDate(value) {
  135. const text = String(value == null ? '' : value).trim();
  136. const m = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/.exec(text);
  137. if (m) return m[1] + pad2(Number(m[2])) + pad2(Number(m[3]));
  138. const compact = text.replace(/\D/g, '');
  139. return /^\d{8}$/.test(compact) ? compact : '';
  140. }
  141. function indexOfHeader(header, name) {
  142. for (let i = 0; i < header.length; i++) if (normalizeHeader(header[i]) === name) return i;
  143. return -1;
  144. }
  145. /**
  146. * 平台导出 → 全字段行
  147. * @param {Array<Array<any>>} cells
  148. * @param {{ school?: string }} options
  149. */
  150. function parsePlatformExport(cells, options = {}) {
  151. const header = ((cells && cells[0]) || []).map((h) => String(h == null ? '' : h).trim());
  152. const iDate = indexOfHeader(header, '日期');
  153. const iShopId = indexOfHeader(header, '门店编号');
  154. const iShopName = indexOfHeader(header, '门店名称');
  155. if (iDate < 0 || iShopId < 0 || iShopName < 0) {
  156. throw parseError('缺少「日期 / 门店编号 / 门店名称」列', 'OPS_FORMAT_UNSUPPORTED');
  157. }
  158. const school = String(options.school || '').trim();
  159. const columns = header.map((h, i) => ({ name: h, col: i })).filter((c) => c.name);
  160. const rows = [];
  161. const shops = new Map();
  162. const dates = new Set();
  163. const warnings = [];
  164. for (let r = 1; r < cells.length; r++) {
  165. const line = cells[r] || [];
  166. const date = normalizeDate(line[iDate]);
  167. const shopId = toShopId(line[iShopId]);
  168. const shopName = normalizeHeader(line[iShopName]);
  169. if (!date || !shopId) {
  170. const anyValue = line.some((v, i) => i !== iDate && v != null && v !== '');
  171. if (anyValue) warnings.push({ row: r + 1, column: date ? '门店编号' : '日期', raw: date ? shopId : String(line[iDate] || ''), reason: 'missing-key' });
  172. continue;
  173. }
  174. if (!shopName) {
  175. warnings.push({ row: r + 1, column: '门店名称', raw: '', reason: 'missing-shop-name' });
  176. continue;
  177. }
  178. const data = {};
  179. for (const c of columns) {
  180. const value = line[c.col];
  181. data[c.name] = value === undefined ? null : value;
  182. }
  183. rows.push({ date, shopId, shopName, school, data });
  184. dates.add(date);
  185. if (!shops.has(shopId)) shops.set(shopId, { key: shopId, shopId, shopName, school, status: '', kind: 'platform' });
  186. }
  187. const columnNames = columns.map((c) => c.name).filter((n) => !['日期', '门店编号', '门店名称'].includes(n));
  188. return { rows, shops: [...shops.values()], warnings, dates: [...dates].sort(), kind: 'platform-export', columns: columnNames };
  189. }
  190. /**
  191. * 日期列 → [{ col, date, metricKey }]
  192. * 年份由调用方给定;列按出现顺序递增,遇到「月份比前一列小 6 以上」视为跨年。
  193. */
  194. function parseDateColumns(header, year) {
  195. const columns = [];
  196. let currentYear = Number(year);
  197. let prevMonth = null;
  198. for (let i = 0; i < header.length; i++) {
  199. const text = normalizeHeader(header[i]);
  200. const m = DATE_COLUMN_RE.exec(text);
  201. if (!m) continue;
  202. const month = Number(m[1]);
  203. const day = Number(m[2]);
  204. if (month < 1 || month > 12 || day < 1 || day > 31) continue;
  205. if (prevMonth !== null && month < prevMonth - 6) currentYear += 1;
  206. prevMonth = month;
  207. const metricKey = METRIC_BY_LABEL[m[3]];
  208. if (!metricKey) continue;
  209. columns.push({
  210. col: i,
  211. date: `${currentYear}${pad2(month)}${pad2(day)}`,
  212. metricKey,
  213. label: normalizeHeader(header[i]),
  214. });
  215. }
  216. return columns;
  217. }
  218. // ---------- 解析 ----------
  219. /**
  220. * 人工统计横表 → 长表行
  221. * @param {Array<Array<any>>} cells
  222. * @param {{ year?: number, school?: string }} options school 非空时覆盖表格里的「学校名称」列
  223. * @returns {{ rows: Array<object>, shops: Array<object>, warnings: Array<object>, dates: string[] }}
  224. */
  225. function parseManualOps(cells, options = {}) {
  226. const year = Number(options.year) || new Date().getFullYear();
  227. const schoolOverride = String(options.school || '').trim();
  228. const header = (cells && cells[0]) || [];
  229. if (!header.length) throw parseError('表格为空');
  230. if (detect(cells) !== 'manual-ops') throw parseError('表头不符合人工统计表格式', 'OPS_FORMAT_UNSUPPORTED');
  231. const cols = {
  232. school: findColumn(header, BASE_COLUMNS.school),
  233. shopName: findColumn(header, BASE_COLUMNS.shopName),
  234. shopId: findColumn(header, BASE_COLUMNS.shopId),
  235. status: findColumn(header, BASE_COLUMNS.status),
  236. };
  237. if (cols.shopName < 0) throw parseError('缺少「店铺名称」列', 'OPS_FORMAT_UNSUPPORTED');
  238. const dateCols = parseDateColumns(header, year);
  239. if (!dateCols.length) throw parseError('未找到日期列(形如 9.9订单量)', 'OPS_FORMAT_UNSUPPORTED');
  240. const rows = [];
  241. const warnings = [];
  242. const shops = new Map();
  243. const dates = new Set();
  244. for (let r = 1; r < cells.length; r++) {
  245. const line = cells[r] || [];
  246. const shopName = normalizeHeader(line[cols.shopName]);
  247. if (!shopName) continue; // 占位行 / 空行
  248. const school = schoolOverride || (cols.school >= 0 ? normalizeHeader(line[cols.school]) : '');
  249. const shopId = cols.shopId >= 0 ? toShopId(line[cols.shopId]) : '';
  250. const status = cols.status >= 0 ? normalizeHeader(line[cols.status]) : '';
  251. const shopKey = shopId || `${school}|${shopName}`;
  252. const byDate = new Map();
  253. for (const dc of dateCols) {
  254. const cleaned = cleanValue(line[dc.col], dc.metricKey);
  255. for (const reason of cleaned.reasons) {
  256. warnings.push({
  257. row: r + 1, // Excel 行号(1 基)
  258. column: header[dc.col] == null ? '' : String(header[dc.col]),
  259. raw: cleaned.rawText,
  260. reason,
  261. shopName,
  262. date: dc.date,
  263. });
  264. }
  265. if (!byDate.has(dc.date)) byDate.set(dc.date, {});
  266. byDate.get(dc.date)[dc.metricKey] = cleaned;
  267. }
  268. if (!shops.has(shopKey)) {
  269. shops.set(shopKey, {
  270. key: shopKey,
  271. shopId,
  272. shopName,
  273. school,
  274. status,
  275. });
  276. }
  277. for (const [date, metrics] of byDate) {
  278. const values = METRIC_KEYS.map((k) => (metrics[k] ? metrics[k].value : null));
  279. const quality = [];
  280. const raw = {};
  281. for (const k of METRIC_KEYS) {
  282. const m = metrics[k];
  283. if (!m) continue;
  284. raw[k] = m.rawText;
  285. for (const reason of m.reasons) {
  286. if (reason === 'missing') continue; // 缺失不当作单元格异常,避免整表标黄
  287. if (!quality.includes(reason)) quality.push(reason);
  288. }
  289. }
  290. if (!shopId && !quality.includes('no-shop-id')) quality.push('no-shop-id');
  291. const row = {
  292. date,
  293. shopKey,
  294. shopId,
  295. shopName,
  296. school,
  297. status,
  298. order_cnt: metrics.order_cnt ? metrics.order_cnt.value : null,
  299. revenue: metrics.revenue ? metrics.revenue.value : null,
  300. exposure: metrics.exposure ? metrics.exposure.value : null,
  301. click_rate: metrics.click_rate ? metrics.click_rate.value : null,
  302. raw,
  303. quality,
  304. };
  305. rows.push({ row, hasValue: values.some((v) => v !== null) });
  306. dates.add(date);
  307. }
  308. }
  309. // 整行(该店全部日期)都没有任何数值 → 视为空行跳过;有数据的店保留全部日期,
  310. // 当天四项皆空也留一行(值全为 null),矩阵显示「–」,保证 店 × 天 结构完整。
  311. const kept = [];
  312. const shopsWithData = new Set();
  313. for (const item of rows) {
  314. if (item.hasValue) shopsWithData.add(item.row.shopKey);
  315. }
  316. for (const item of rows) {
  317. if (!shopsWithData.has(item.row.shopKey)) continue;
  318. kept.push(item.row);
  319. }
  320. return {
  321. rows: kept,
  322. shops: [...shops.values()].filter((s) => shopsWithData.has(s.key)),
  323. warnings,
  324. dates: [...dates].sort(),
  325. };
  326. }
  327. /** 一步到位:Buffer → 解析结果 */
  328. function parseWorkbook(buffer, options = {}) {
  329. const { sheets } = readXlsx(buffer);
  330. if (!sheets.length) throw parseError('未找到工作表', 'OPS_FORMAT_UNSUPPORTED');
  331. return parseManualOps(sheets[0].rows, options);
  332. }
  333. /** 解析器注册表(后续加平台导出格式时在此追加) */
  334. const PARSERS = [
  335. { kind: 'manual-ops', detect, parse: parseManualOps },
  336. ];
  337. function detectKind(cells) {
  338. if (detect(cells) === 'manual-ops') return 'manual-ops';
  339. if (detectPlatformExport(cells)) return 'platform-export';
  340. return null;
  341. }
  342. function detectBuffer(buffer) {
  343. const { sheets } = readXlsx(buffer);
  344. const cells = sheets.length ? sheets[0].rows : [];
  345. return detectKind(cells);
  346. }
  347. /** 一步到位(按格式分流):Buffer → { kind, rows, shops, warnings, dates } */
  348. function parseAny(buffer, options = {}) {
  349. const { sheets } = readXlsx(buffer);
  350. const cells = sheets.length ? sheets[0].rows : [];
  351. const kind = detectKind(cells);
  352. if (kind === 'manual-ops') return { kind, ...parseManualOps(cells, options) };
  353. if (kind === 'platform-export') return parsePlatformExport(cells, options);
  354. throw parseError('暂不支持该表格格式', 'OPS_FORMAT_UNSUPPORTED');
  355. }
  356. module.exports = {
  357. METRICS,
  358. METRIC_KEYS,
  359. PARSERS,
  360. detect,
  361. detectPlatformExport,
  362. detectKind,
  363. parseAny,
  364. parsePlatformExport,
  365. detectBuffer,
  366. parseManualOps,
  367. parseWorkbook,
  368. cleanValue,
  369. parseDateColumns,
  370. };