ops-import.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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. /**
  127. * 日期列 → [{ col, date, metricKey }]
  128. * 年份由调用方给定;列按出现顺序递增,遇到「月份比前一列小 6 以上」视为跨年。
  129. */
  130. function parseDateColumns(header, year) {
  131. const columns = [];
  132. let currentYear = Number(year);
  133. let prevMonth = null;
  134. for (let i = 0; i < header.length; i++) {
  135. const text = normalizeHeader(header[i]);
  136. const m = DATE_COLUMN_RE.exec(text);
  137. if (!m) continue;
  138. const month = Number(m[1]);
  139. const day = Number(m[2]);
  140. if (month < 1 || month > 12 || day < 1 || day > 31) continue;
  141. if (prevMonth !== null && month < prevMonth - 6) currentYear += 1;
  142. prevMonth = month;
  143. const metricKey = METRIC_BY_LABEL[m[3]];
  144. if (!metricKey) continue;
  145. columns.push({
  146. col: i,
  147. date: `${currentYear}${pad2(month)}${pad2(day)}`,
  148. metricKey,
  149. label: normalizeHeader(header[i]),
  150. });
  151. }
  152. return columns;
  153. }
  154. // ---------- 解析 ----------
  155. /**
  156. * 人工统计横表 → 长表行
  157. * @param {Array<Array<any>>} cells
  158. * @param {{ year?: number, school?: string }} options school 非空时覆盖表格里的「学校名称」列
  159. * @returns {{ rows: Array<object>, shops: Array<object>, warnings: Array<object>, dates: string[] }}
  160. */
  161. function parseManualOps(cells, options = {}) {
  162. const year = Number(options.year) || new Date().getFullYear();
  163. const schoolOverride = String(options.school || '').trim();
  164. const header = (cells && cells[0]) || [];
  165. if (!header.length) throw parseError('表格为空');
  166. if (detect(cells) !== 'manual-ops') throw parseError('表头不符合人工统计表格式', 'OPS_FORMAT_UNSUPPORTED');
  167. const cols = {
  168. school: findColumn(header, BASE_COLUMNS.school),
  169. shopName: findColumn(header, BASE_COLUMNS.shopName),
  170. shopId: findColumn(header, BASE_COLUMNS.shopId),
  171. status: findColumn(header, BASE_COLUMNS.status),
  172. };
  173. if (cols.shopName < 0) throw parseError('缺少「店铺名称」列', 'OPS_FORMAT_UNSUPPORTED');
  174. const dateCols = parseDateColumns(header, year);
  175. if (!dateCols.length) throw parseError('未找到日期列(形如 9.9订单量)', 'OPS_FORMAT_UNSUPPORTED');
  176. const rows = [];
  177. const warnings = [];
  178. const shops = new Map();
  179. const dates = new Set();
  180. for (let r = 1; r < cells.length; r++) {
  181. const line = cells[r] || [];
  182. const shopName = normalizeHeader(line[cols.shopName]);
  183. if (!shopName) continue; // 占位行 / 空行
  184. const school = schoolOverride || (cols.school >= 0 ? normalizeHeader(line[cols.school]) : '');
  185. const shopId = cols.shopId >= 0 ? toShopId(line[cols.shopId]) : '';
  186. const status = cols.status >= 0 ? normalizeHeader(line[cols.status]) : '';
  187. const shopKey = shopId || `${school}|${shopName}`;
  188. const byDate = new Map();
  189. for (const dc of dateCols) {
  190. const cleaned = cleanValue(line[dc.col], dc.metricKey);
  191. for (const reason of cleaned.reasons) {
  192. warnings.push({
  193. row: r + 1, // Excel 行号(1 基)
  194. column: header[dc.col] == null ? '' : String(header[dc.col]),
  195. raw: cleaned.rawText,
  196. reason,
  197. shopName,
  198. date: dc.date,
  199. });
  200. }
  201. if (!byDate.has(dc.date)) byDate.set(dc.date, {});
  202. byDate.get(dc.date)[dc.metricKey] = cleaned;
  203. }
  204. if (!shops.has(shopKey)) {
  205. shops.set(shopKey, {
  206. key: shopKey,
  207. shopId,
  208. shopName,
  209. school,
  210. status,
  211. });
  212. }
  213. for (const [date, metrics] of byDate) {
  214. const values = METRIC_KEYS.map((k) => (metrics[k] ? metrics[k].value : null));
  215. const quality = [];
  216. const raw = {};
  217. for (const k of METRIC_KEYS) {
  218. const m = metrics[k];
  219. if (!m) continue;
  220. raw[k] = m.rawText;
  221. for (const reason of m.reasons) {
  222. if (reason === 'missing') continue; // 缺失不当作单元格异常,避免整表标黄
  223. if (!quality.includes(reason)) quality.push(reason);
  224. }
  225. }
  226. if (!shopId && !quality.includes('no-shop-id')) quality.push('no-shop-id');
  227. const row = {
  228. date,
  229. shopKey,
  230. shopId,
  231. shopName,
  232. school,
  233. status,
  234. order_cnt: metrics.order_cnt ? metrics.order_cnt.value : null,
  235. revenue: metrics.revenue ? metrics.revenue.value : null,
  236. exposure: metrics.exposure ? metrics.exposure.value : null,
  237. click_rate: metrics.click_rate ? metrics.click_rate.value : null,
  238. raw,
  239. quality,
  240. };
  241. rows.push({ row, hasValue: values.some((v) => v !== null) });
  242. dates.add(date);
  243. }
  244. }
  245. // 整行(该店全部日期)都没有任何数值 → 视为空行跳过;有数据的店保留全部日期,
  246. // 当天四项皆空也留一行(值全为 null),矩阵显示「–」,保证 店 × 天 结构完整。
  247. const kept = [];
  248. const shopsWithData = new Set();
  249. for (const item of rows) {
  250. if (item.hasValue) shopsWithData.add(item.row.shopKey);
  251. }
  252. for (const item of rows) {
  253. if (!shopsWithData.has(item.row.shopKey)) continue;
  254. kept.push(item.row);
  255. }
  256. return {
  257. rows: kept,
  258. shops: [...shops.values()].filter((s) => shopsWithData.has(s.key)),
  259. warnings,
  260. dates: [...dates].sort(),
  261. };
  262. }
  263. /** 一步到位:Buffer → 解析结果 */
  264. function parseWorkbook(buffer, options = {}) {
  265. const { sheets } = readXlsx(buffer);
  266. if (!sheets.length) throw parseError('未找到工作表', 'OPS_FORMAT_UNSUPPORTED');
  267. return parseManualOps(sheets[0].rows, options);
  268. }
  269. /** 解析器注册表(后续加平台导出格式时在此追加) */
  270. const PARSERS = [
  271. { kind: 'manual-ops', detect, parse: parseManualOps },
  272. ];
  273. function detectBuffer(buffer) {
  274. const { sheets } = readXlsx(buffer);
  275. const cells = sheets.length ? sheets[0].rows : [];
  276. for (const parser of PARSERS) {
  277. if (parser.detect(cells)) return parser.kind;
  278. }
  279. return null;
  280. }
  281. module.exports = {
  282. METRICS,
  283. METRIC_KEYS,
  284. PARSERS,
  285. detect,
  286. detectBuffer,
  287. parseManualOps,
  288. parseWorkbook,
  289. cleanValue,
  290. parseDateColumns,
  291. };