napos-service.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. 'use strict';
  2. /**
  3. * 数据报表 · 取数服务层
  4. * ----------------------------------------------------------------------------
  5. * 桥接 napos/(淘宝闪购商家客户端 CDP 取数引擎)与 reports-store(本地数据湖)。
  6. *
  7. * 取数通道:附着本机商家客户端调试端口(默认 9669),由客户端页面自身的登录态
  8. * 代发请求(非爬虫、不模拟点击);数据中心接口按域自动补签名。
  9. *
  10. * 数据来源(数据中心 lsycm · historyOverview):
  11. * ds 日期 / valid_order_cnt 有效订单 / gross_amt 营业额 / income_amt 收入 /
  12. * expense_amt 支出 / exp_uv 曝光人数 / order_user_cnt 下单人数 /
  13. * clk_exp_rate 进店转化率 / ord_clk_rate 下单转化率 / per_actual_pay 客单价 …
  14. *
  15. * 营业时长 / 异常关店时长 / 商品销售额:当前接口目录暂无对应字段,
  16. * 作为预留列返回 null,取数层保留原始返回值,后续补源即可回填。
  17. */
  18. const kit = require('./napos/kit.js');
  19. const store = require('./reports-store.js');
  20. const harvest = require('./napos/harvest.js');
  21. const DEFAULT_CDP_PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
  22. // ---------- BI 列注册表(语义层)----------
  23. // synonyms / description 供「智能问数」的 LLM 做实体对齐与口径解释
  24. const REPORT_COLUMNS = [
  25. { key: 'account', label: '账号名', type: 'text', locked: true, aggregable: false,
  26. synonyms: ['账号', '账号名', '账户', '主体'],
  27. description: '数据所属的商家账号(主体)名称;多账号数据合并查看或导出时用于区分来源' },
  28. { key: 'date', label: '日期', type: 'date', locked: true, aggregable: false,
  29. synonyms: ['日期', '天', '哪天', '日期时间'],
  30. description: '数据日期,格式 YYYYMMDD;当天数据未结算,有效区间一般为 T-1 及更早' },
  31. { key: 'shopName', label: '门店名称', type: 'text', locked: true, aggregable: false,
  32. synonyms: ['门店', '店铺', '店', '门店名'],
  33. description: '门店名称,支持模糊筛选' },
  34. { key: 'bizHours', label: '营业时长(h)', type: 'hours', reserved: true,
  35. synonyms: ['营业时长', '营业时间', '开店时长'],
  36. description: '预留列:当日营业小时数,数据源暂未接入' },
  37. { key: 'abnormalCloseHours', label: '异常关店时长(h)', type: 'hours', reserved: true,
  38. synonyms: ['异常关店', '异常关店时长', '关店时长', '异常闭店'],
  39. description: '预留列:非计划性关店时长,数据源暂未接入' },
  40. { key: 'busyLevel', label: '是否营业', type: 'text', aggregable: false,
  41. synonyms: ['是否营业', '营业状态', '营业中', '开门没', '开店没', '营业没'],
  42. description: '同步时客户端回报的门店营业状态快照:营业中 / 曾营业(当日曾开业,查询时已打烊)/ 休息 / 暂停营业 / 未开业(无效门店);'
  43. + '仅反映同步当天起的状态,更早的历史日期无记录' },
  44. { key: 'validOrderCnt', label: '有效订单', type: 'int',
  45. synonyms: ['有效订单', '订单量', '单量', '订单数', '有效单量'],
  46. description: '剔除取消/无效后的订单量' },
  47. { key: 'incomeAmt', label: '收入(元)', type: 'money',
  48. synonyms: ['收入', '实际收入', '到账', '营收'],
  49. description: '商家实际收入(元);营业额 = 收入 + 支出' },
  50. { key: 'grossAmt', label: '营业额(元)', type: 'money',
  51. synonyms: ['营业额', '流水', 'GMV', 'gmv', '销售额', '交易额'],
  52. description: '营业额(元),口径 = 收入 + 支出;做排行/对比的首选指标' },
  53. { key: 'goodsSaleAmt', label: '商品销售额(元)', type: 'money', reserved: true,
  54. synonyms: ['商品销售额', '商品销售', '菜品销售额'],
  55. description: '预留列:仅商品部分的销售金额,数据源暂未接入' },
  56. { key: 'expUv', label: '曝光人数', type: 'int',
  57. synonyms: ['曝光', '曝光人数', '曝光量', '展现人数'],
  58. description: '看到店铺的去重人数' },
  59. { key: 'orderUserCnt', label: '下单人数', type: 'int',
  60. synonyms: ['下单人数', '下单用户', '购买人数', '下单用户数'],
  61. description: '当日下单的去重用户数' },
  62. { key: 'clkExpRate', label: '进店转化率', type: 'pct',
  63. synonyms: ['进店转化率', '进店率', '点击率', '入店转化率'],
  64. description: '进店人数 / 曝光人数;≥9% 健康,<6% 偏低' },
  65. { key: 'ordClkRate', label: '下单转化率', type: 'pct',
  66. synonyms: ['下单转化率', '下单率', '成交转化率', '访购率'],
  67. description: '下单人数 / 进店人数;≥2.5% 健康,<2% 偏低' },
  68. { key: 'perActualPay', label: '客单价(元)', type: 'money',
  69. synonyms: ['客单价', '均价', '笔单价'],
  70. description: '营业额 / 有效订单(元)' },
  71. { key: 'expenseAmt', label: '支出(元)', type: 'money',
  72. synonyms: ['支出', '费用', '成本', '平台扣费'],
  73. description: '支出(元),含平台佣金、推广费用等' },
  74. { key: 'actOrdCnt', label: '活动订单', type: 'int',
  75. synonyms: ['活动订单', '活动单量', '营销订单'],
  76. description: '参与营销活动的订单量' },
  77. { key: 'actSubsidyAmt', label: '活动补贴(元)', type: 'money',
  78. synonyms: ['活动补贴', '补贴金额', '商家补贴'],
  79. description: '活动中商家承担的补贴金额(元)' },
  80. { key: 'shopRoi', label: '推广ROI', type: 'num',
  81. synonyms: ['ROI', 'roi', '投产比', '推广roi'],
  82. description: '推广投入产出比;大盘口径常为 0' },
  83. { key: 'rebuyRate30d', label: '30日复购率', type: 'pct',
  84. synonyms: ['复购率', '30日复购', '老客回购率'],
  85. description: '近 30 天老客复购比例' },
  86. ];
  87. const COLUMN_MAP = Object.fromEntries(REPORT_COLUMNS.map((c) => [c.key, c]));
  88. // ---------- 原始字段 → 标准行 ----------
  89. const FIELD_MAP = {
  90. valid_order_cnt: 'validOrderCnt',
  91. gross_amt: 'grossAmt',
  92. income_amt: 'incomeAmt',
  93. expense_amt: 'expenseAmt',
  94. exp_uv: 'expUv',
  95. order_user_cnt: 'orderUserCnt',
  96. clk_exp_rate: 'clkExpRate',
  97. ord_clk_rate: 'ordClkRate',
  98. per_actual_pay: 'perActualPay',
  99. act_ord_cnt: 'actOrdCnt',
  100. act_shop_subsidy_amt: 'actSubsidyAmt',
  101. shop_roi: 'shopRoi',
  102. rebuy_rate_30d: 'rebuyRate30d',
  103. };
  104. // 全字段落库:原始返回一字不差保留,不做任何裁剪。
  105. // historyOverview 每行约 60+ 字段 = 基础 13 项(ds + 12 指标)+ 环比差值 _diff(12)
  106. // + 同行均值/排名 _mall_avg(_rank)(25) + 上期对比 _last_period(13)。
  107. // 后续渲染加工 / 比对分析直接从 raw 取用,无需重新取数。
  108. function captureRaw(raw) {
  109. return { ...(raw || {}) };
  110. }
  111. function normalizeRow(rawDaily, ctx) {
  112. const row = {
  113. date: String(rawDaily.ds || ''),
  114. scope: ctx.scope,
  115. shopId: ctx.shopId == null ? '' : String(ctx.shopId),
  116. shopName: ctx.shopName || '',
  117. chainId: ctx.chainId == null ? '' : String(ctx.chainId),
  118. chainName: ctx.chainName || '',
  119. bizHours: null,
  120. abnormalCloseHours: null,
  121. goodsSaleAmt: null,
  122. syncedAt: new Date().toISOString(),
  123. raw: captureRaw(rawDaily),
  124. };
  125. for (const [src, dst] of Object.entries(FIELD_MAP)) {
  126. const v = rawDaily[src];
  127. row[dst] = v == null || v === '' ? 0 : Number(v);
  128. }
  129. return row;
  130. }
  131. // 范围汇总行(接口返回 ds='all'):唯一携带同行均值/排名与整段环比的行。
  132. // 以 scope='summary'、date=区间末日 入库,与公司/连锁/门店行互不干扰。
  133. function normalizeSummaryRow(allRow, ctx) {
  134. const row = normalizeRow({ ...allRow, ds: ctx.date }, ctx);
  135. row.scope = 'summary';
  136. row.shopId = '';
  137. row.shopName = '';
  138. return row;
  139. }
  140. // ---------- 连接状态 ----------
  141. /** 按平台给出「以调试端口启动商家客户端」的可执行指引(结构化数据,前端分步渲染) */
  142. function cdpLaunchHint(port = DEFAULT_CDP_PORT) {
  143. if (process.platform === 'win32') {
  144. return {
  145. title: 'Windows 带参启动客户端',
  146. steps: [
  147. { text: '任务栏右键完全退出客户端,任务管理器确认进程已退出' },
  148. { text: '右键桌面快捷方式 → 属性 →「目标」末尾追加一个空格和', code: `--remote-debugging-port=${port}` },
  149. { text: '确定后,用该快捷方式启动客户端' },
  150. ],
  151. note: '客户端每次重启都要用这个快捷方式,否则端口不会开启。',
  152. };
  153. }
  154. return {
  155. title: 'macOS 带参启动客户端',
  156. steps: [
  157. { text: '⌘Q 完全退出客户端(活动监视器确认进程已退出)' },
  158. { text: '终端执行:', code: `open -a "淘宝闪购商家版" --args --remote-debugging-port=${port}` },
  159. { text: '应用名以实际为准;也可直接运行', code: `"<客户端.app>/Contents/MacOS/<可执行文件>" --remote-debugging-port=${port}` },
  160. ],
  161. note: '客户端每次重启都要带此参数,否则端口不会开启。',
  162. };
  163. }
  164. async function naposStatus() {
  165. const out = { port: DEFAULT_CDP_PORT, connected: false };
  166. try {
  167. const st = await kit.status(DEFAULT_CDP_PORT);
  168. out.connected = !!st.portOk;
  169. out.browser = st.browser || null;
  170. out.targets = (st.targets || []).length;
  171. out.contexts = (st.contexts || []).length;
  172. if (st.probeError) out.probeError = String(st.probeError);
  173. } catch (e) {
  174. out.error = e.message || String(e);
  175. }
  176. if (!out.connected) out.hint = cdpLaunchHint();
  177. // 自检:本机能否连通调试端口(给用户的浏览器直测地址)
  178. out.selfCheckUrl = `http://127.0.0.1:${DEFAULT_CDP_PORT}/json/version`;
  179. return out;
  180. }
  181. // ---------- 取数诊断(网页一键版 · 全程只读) ----------
  182. // 给「装了商家客户端」的电脑用:网页连点侧边栏版本号出现入口,一键跑门店级取数对照实验,
  183. // 报告在页面直接看 / 复制回开发侧。覆盖三类定位:
  184. // · 门店维度参数(现行 chainId=-1+shopListIn 是否有数,与 B/D 变体对照)
  185. // · 指标字段存在性(下单人数门店级是否缺失、营业时长/异常关店时长是否有源)
  186. // · 暂停营业门店清单(busyLevel 当前状态快照)
  187. let diagnoseRunning = false;
  188. const DIAGNOSE_CORE_METRICS = ['valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv',
  189. 'order_user_cnt', 'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt',
  190. 'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d'];
  191. function diagnoseMetricProbe(row) {
  192. if (!row) return null;
  193. const keys = Object.keys(row);
  194. const coreMetrics = {};
  195. for (const k of DIAGNOSE_CORE_METRICS) {
  196. coreMetrics[k] = Object.prototype.hasOwnProperty.call(row, k) ? row[k] : '(字段不存在)';
  197. }
  198. return {
  199. fieldCount: keys.length,
  200. coreMetrics,
  201. relatedExtraKeys: keys
  202. .filter((k) => /user|buyer|hour|close|open_time|biz|pause|rest/i.test(k))
  203. .map((k) => `${k}=${JSON.stringify(row[k])}`),
  204. };
  205. }
  206. async function runNaposDiagnose({ date, shopId } = {}) {
  207. if (diagnoseRunning) {
  208. const e = new Error('已有诊断在进行中,请稍候');
  209. e.code = 'DIAGNOSE_BUSY'; e.status = 429;
  210. throw e;
  211. }
  212. diagnoseRunning = true;
  213. const t0 = Date.now();
  214. const report = { generatedAt: new Date().toISOString(), port: DEFAULT_CDP_PORT };
  215. try {
  216. const st = await kit.status(DEFAULT_CDP_PORT);
  217. report.status = { portOk: !!st.portOk, browser: st.browser || null, targets: (st.targets || []).length, targetUrls: (st.targets || []).slice(0, 6) };
  218. if (!st.portOk) {
  219. report.conclusion = '客户端不在线:请完全退出商家客户端,用「启动.bat」附带调试端口的方式重启并登录后再诊断。';
  220. return report;
  221. }
  222. // 组织树:失败自动重试一次(热切换账号后内部页面常需几秒恢复),并把原始返回带回报告
  223. let tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
  224. if (!tree.ok || !tree.merchant) {
  225. await new Promise((res) => setTimeout(res, 1500));
  226. tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
  227. }
  228. report.treeDiag = {
  229. ok: !!tree.ok && !!tree.merchant,
  230. code: tree.code ?? null,
  231. msg: tree.msg || null,
  232. authFail: !!tree.authFail,
  233. risk: !!tree.risk,
  234. rawHead: (() => { try { return JSON.stringify(tree.raw).slice(0, 600); } catch { return null; } })(),
  235. };
  236. if (!tree.ok || !tree.merchant) {
  237. report.conclusion = '客户端端口在线但读不到门店组织树:多半是刚切换/新登录账号后内部页面状态未刷新。'
  238. + '请在客户端里打开一次「门店管理」页面,仍不行就完全退出客户端、以调试端口重启并重新登录,再重跑本诊断。'
  239. + (report.treeDiag.code || report.treeDiag.msg ? `(服务端返回:${[report.treeDiag.code, report.treeDiag.msg].filter(Boolean).join(' ')})` : '');
  240. return report;
  241. }
  242. report.merchant = tree.merchant || null;
  243. report.chains = (tree.chains || []).map((c) => ({ chainId: String(c.chainId), chainName: c.chainName }));
  244. const shops = (tree.shops || []).map((s) => ({
  245. shopId: String(s.shopId), shopName: s.shopName, chainId: String(s.chainId || ''), busyLevel: s.busyLevel || null,
  246. }));
  247. report.shopCount = shops.length;
  248. report.busyLevelSummary = shops.reduce((a, s) => { const b = s.busyLevel || '(未知)'; a[b] = (a[b] || 0) + 1; return a; }, {});
  249. report.pausedShops = shops.filter((s) => s.busyLevel === 'SUSPEND_BUSINESS' || s.busyLevel === 'INVALID');
  250. if (!report.chains.length) {
  251. report.conclusion = '组织树没有连锁信息,无法构造连锁对照实验。';
  252. return report;
  253. }
  254. const chainId = report.chains[0].chainId;
  255. const z = (n) => String(n).padStart(2, '0');
  256. const ymdShift = (ymd, delta) => {
  257. const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  258. d.setDate(d.getDate() + delta);
  259. return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
  260. };
  261. const now = new Date();
  262. const todayYmd = `${now.getFullYear()}${z(now.getMonth() + 1)}${z(now.getDate())}`;
  263. const testDate = /^\d{8}$/.test(date || '') ? date : ymdShift(todayYmd, -1);
  264. report.testDate = testDate;
  265. const picked = [];
  266. if (/^\d+$/.test(shopId || '')) {
  267. const one = shops.find((s) => s.shopId === shopId);
  268. if (one) picked.push(one);
  269. }
  270. for (const s of shops) {
  271. if (picked.length >= 2) break;
  272. if (!picked.includes(s)) picked.push(s);
  273. }
  274. report.testShops = picked.map((s) => ({ shopId: s.shopId, shopName: s.shopName, busyLevel: s.busyLevel || null }));
  275. const sumOf = (rows, f) => Math.round((rows || []).reduce((s2, r) => s2 + (Number(r[f]) || 0), 0) * 100) / 100;
  276. const runCase = async (tag, params) => {
  277. const out = { tag, chainId: params.chainId, shopListIn: params.shopListIn, rows: 0, ordersSum: 0, grossSum: 0, expSum: 0, orderUserSum: 0 };
  278. try {
  279. const r = await kit.statsHistory({ port: DEFAULT_CDP_PORT, from: params.from, to: params.to, dateType: 'self_define',
  280. chainId: params.chainId, shopListIn: params.shopListIn });
  281. const daily = r.daily || [];
  282. out.rows = daily.length;
  283. out.ordersSum = sumOf(daily, 'valid_order_cnt');
  284. out.grossSum = sumOf(daily, 'gross_amt');
  285. out.expSum = sumOf(daily, 'exp_uv');
  286. out.orderUserSum = sumOf(daily, 'order_user_cnt');
  287. out.firstRow = daily[0] ? (({ raw, ...rest }) => rest)(daily[0]) : null;
  288. out.metricProbe = diagnoseMetricProbe(daily[0]);
  289. out.ok = !!r.ok;
  290. if (r.shapeWarning) out.shapeWarning = true;
  291. // 传输层证据:区分「请求没成功(未登录/风控/HTTP错误)」和「成功但没数据」——
  292. // 此前这两种在报告里都长成 0 行,KNCT666 类问题因此被误读成"当天无经营数据"
  293. out.status = r.status != null ? r.status : null;
  294. if (r.risk) out.risk = true;
  295. if (r.authFail) out.authFail = true;
  296. if (r.code != null) out.code = r.code;
  297. if (r.msg) out.msg = r.msg;
  298. } catch (e) {
  299. out.error = e.message || String(e);
  300. }
  301. return out;
  302. };
  303. report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
  304. // 连锁级查不到数时追加三组对照,一次跑完即可区分「请求失败 / 当天无数据 / 账号范围或权限」:
  305. // E 账号默认范围(-1) F 账号根ID当连锁(UNIT 型账号的树根不是 CHAIN) G 近7天窗口(排除单日无数据)
  306. report.chainFallbacks = [];
  307. if (!report.chainControl.rows || !report.chainControl.ordersSum) {
  308. const fallbacks = [
  309. { tag: 'E 对照 chainId=-1·不带门店', params: { from: testDate, to: testDate, chainId: '-1', shopListIn: '' } },
  310. { tag: `F 对照 chainId=${report.merchant && report.merchant.id}·账号根·不带门店`, params: { from: testDate, to: testDate, chainId: String((report.merchant && report.merchant.id) || ''), shopListIn: '' } },
  311. { tag: `G 对照 近7天·chainId=${chainId}`, params: { from: ymdShift(testDate, -6), to: testDate, chainId, shopListIn: '' } },
  312. ];
  313. for (const f of fallbacks) {
  314. const r = await runCase(f.tag, f.params);
  315. report.chainFallbacks.push(r);
  316. await new Promise((res) => setTimeout(res, 600));
  317. }
  318. }
  319. report.cases = [];
  320. for (const s of picked) {
  321. for (const c of [
  322. { tag: 'A 现行 chainId=-1+shopListIn', chainId: '-1' },
  323. { tag: `B 变体 chainId=${chainId}+shopListIn`, chainId },
  324. { tag: 'D 变体 chainId=(空)+shopListIn', chainId: '' },
  325. ]) {
  326. const r = await runCase(c.tag, { from: testDate, to: testDate, chainId: c.chainId, shopListIn: s.shopId });
  327. r.shopId = s.shopId;
  328. r.shopName = s.shopName;
  329. report.cases.push(r);
  330. await new Promise((res) => setTimeout(res, 600));
  331. }
  332. }
  333. const chainKeys = new Set(Object.keys(report.chainControl.firstRow || {}));
  334. for (const c of report.cases) {
  335. if (!c.firstRow) continue;
  336. c.missingVsChain = [...chainKeys].filter((k) => !(k in c.firstRow));
  337. }
  338. // 结论:把判定直接写成人话,并区分「请求失败」和「真没数据」
  339. const lines = [];
  340. const cc = report.chainControl;
  341. const isBadStatus = (c) => c && c.status != null && (c.status < 200 || c.status >= 300);
  342. const transportFail = (c) => !!c && !!(c.error || c.authFail || c.risk || isBadStatus(c)
  343. || (c.code != null && String(c.code) !== '0'));
  344. const transportReason = (c) => [c.error && '异常', c.authFail && '未登录', c.risk && '风控',
  345. isBadStatus(c) && `HTTP ${c.status}`, c.code != null && String(c.code) !== '0' && `code=${c.code}`,
  346. c.msg].filter(Boolean).join('/');
  347. if (!cc || !cc.rows || !cc.ordersSum) {
  348. const bad = [cc, ...(report.chainFallbacks || [])].filter(transportFail);
  349. if (bad.length) {
  350. lines.push(`数据中心(生意参谋)接口请求没有成功(${[...new Set(bad.map(transportReason))].slice(0, 3).join(';')})——这不是「没数据」。`
  351. + '多半是这个账号的数据中台登录态没恢复:请在客户端里点开一次「数据中心」页面,仍不行就完全退出客户端、带调试端口重启并重新登录,再诊断一次。');
  352. } else {
  353. const week = (report.chainFallbacks || []).find((c) => String(c.tag).startsWith('G') && c.rows && c.ordersSum);
  354. if (week) {
  355. lines.push(`查询当天(${report.testDate})确实没有数据,但近 7 天有数(有效订单 ${week.ordersSum})——参数与权限都正常,直接重新同步该主体即可取到数。`);
  356. } else {
  357. lines.push('各参数形式下连锁级均为零:该日期可能无经营数据,或该账号在数据中心无此范围的数据权限。可换一个确定有数的日期再诊断;若客户端「数据中心」页面里也看不到数,需平台侧确认权限。');
  358. }
  359. }
  360. } else {
  361. lines.push(`连锁对照正常(${cc.rows} 行 / 有效订单 ${cc.ordersSum} / 营业额 ${cc.grossSum})。`);
  362. const hit = report.cases.filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
  363. if (hit.length) {
  364. lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}。`
  365. + '同步已内置参数自动切换——把程序更新到最新版后,对该主体重新同步一次即可修正,无需人工配置。');
  366. } else {
  367. lines.push('所有参数形式下门店维度均为零:需核对数据权限/门店经营状态(详见各变体原始返回首行)。');
  368. }
  369. const probeSrc = hit[0] || report.cases.find((c) => c.metricProbe);
  370. const probe = probeSrc && probeSrc.metricProbe;
  371. if (probe) {
  372. const ouc = probe.coreMetrics.order_user_cnt;
  373. lines.push(ouc === '(字段不存在)'
  374. ? '门店级返回中「下单人数(order_user_cnt)」字段不存在——上游门店粒度不提供该指标,报表该列将恒为 0。'
  375. : `门店级「下单人数(order_user_cnt)」字段存在,样本值 ${JSON.stringify(ouc)}。`);
  376. }
  377. const hourKeys = [cc, ...report.cases]
  378. .flatMap((c) => (c.metricProbe && c.metricProbe.relatedExtraKeys) || []);
  379. lines.push(hourKeys.length
  380. ? `原始返回中与时长/关店相关的键:${[...new Set(hourKeys)].slice(0, 8).join(';')}`
  381. : '原始返回中没有任何营业时长/异常关店相关字段——这两列为预留列,上游接口暂不提供。');
  382. }
  383. report.conclusionLines = lines;
  384. report.conclusion = lines.join(' ');
  385. return report;
  386. } finally {
  387. report.durationMs = Date.now() - t0;
  388. diagnoseRunning = false;
  389. }
  390. }
  391. // ---------- 主体结构(多主体 tab 的来源) ----------
  392. let treeCache = { at: 0, tree: null };
  393. async function fetchTree({ refresh = false } = {}) {
  394. if (!refresh && treeCache.tree && Date.now() - treeCache.at < 5 * 60 * 1000) {
  395. return treeCache.tree;
  396. }
  397. const tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
  398. if (!tree.ok || !tree.merchant) {
  399. // 新账号/热切换账号后内部页面登录态常失效:把服务端真实原因带出来,别只说「未知错误」
  400. const reason = [tree.code, tree.msg, tree.authFail ? '登录态失效' : null]
  401. .filter(Boolean).join(' ') || `HTTP ${tree.status || '?'}`;
  402. const err = new Error(`读取门店组织树失败:${reason}。若刚切换过账号,请完全退出客户端后以调试端口重启并重新登录,再重试`);
  403. err.code = 'NAPOS_TREE_FAIL';
  404. throw err;
  405. }
  406. const entity = store.upsertEntity({
  407. merchantId: (tree.merchant && tree.merchant.id) || 'default',
  408. merchantName: (tree.merchant && tree.merchant.name) || '默认主体',
  409. chains: tree.chains.map((c) => ({ chainId: String(c.chainId), chainName: c.chainName })),
  410. shops: tree.shops.map((s) => ({
  411. shopId: String(s.shopId), shopName: s.shopName,
  412. chainId: s.chainId ? String(s.chainId) : '', chainName: s.chainName || '',
  413. busyLevel: s.busyLevel || null,
  414. })),
  415. }).entity;
  416. // 缓存必须存补齐 entity 后的完整对象:缓存的消费方(runSync / tree 路由)都依赖 tree.entity
  417. const enriched = { ...tree, entity };
  418. treeCache = { at: Date.now(), tree: enriched };
  419. return enriched;
  420. }
  421. // ---------- 同步任务 ----------
  422. const syncState = {
  423. running: false,
  424. cancelRequested: false,
  425. startedAt: null,
  426. finishedAt: null,
  427. error: null,
  428. entity: null,
  429. from: null,
  430. to: null,
  431. planTotal: 0,
  432. done: 0,
  433. current: '',
  434. added: 0,
  435. updated: 0,
  436. failed: 0,
  437. steps: [],
  438. };
  439. function syncSnapshot() {
  440. const { running, cancelRequested, startedAt, finishedAt, error, entity, from, to,
  441. planTotal, done, current, added, updated, failed, variantHits } = syncState;
  442. return { running, cancelRequested, startedAt, finishedAt, error,
  443. entity, from, to, planTotal, done, current, added, updated, failed, variantHits: variantHits || 0 };
  444. }
  445. function dayShift(ymd, delta) {
  446. const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  447. d.setDate(d.getDate() + delta);
  448. const z = (n) => String(n).padStart(2, '0');
  449. return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
  450. }
  451. function yesterday() { return dayShift(kit.today(), -1); }
  452. /**
  453. * 同步一个区间到本地数据湖。
  454. * scope:
  455. * 'company' — 仅全公司聚合行
  456. * 'chain' — 各连锁聚合行
  457. * 'shop' — 逐店明细行(可选 chainId 限定 / shopIds 限定)
  458. * 'all' — 以上全部(默认)
  459. */
  460. async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {}) {
  461. if (syncState.running) {
  462. const err = new Error('已有同步任务在进行中');
  463. err.code = 'SYNC_BUSY';
  464. throw err;
  465. }
  466. // 先同步置位,避免忙碌检查与实际启动之间因 await 出现并发窗口
  467. Object.assign(syncState, {
  468. running: true, cancelRequested: false, startedAt: new Date().toISOString(), finishedAt: null,
  469. error: null, entity: null, from: null, to: null,
  470. planTotal: 0, done: 0, current: '准备中', added: 0, updated: 0, failed: 0, steps: [], variantHits: 0,
  471. });
  472. try {
  473. const tree = await fetchTree();
  474. const entity = tree.entity;
  475. // 营业状态快照:busyLevel 反映的是「现在」,挂在同步发生的当天;失败不阻塞同步
  476. try {
  477. const lvShops = (tree.shops || []).map((s) => ({
  478. shopId: String(s.shopId), shopName: s.shopName,
  479. chainId: s.chainId ? String(s.chainId) : '', busyLevel: s.busyLevel || null,
  480. }));
  481. if (lvShops.length) store.saveBusyStatus(entity.key, kit.today(), lvShops);
  482. } catch { /* 快照失败不影响取数 */ }
  483. const fromDay = kit.normDay(from || dayShift(yesterday(), -29));
  484. const toDay = kit.normDay(to || yesterday());
  485. if (!/^\d{8}$/.test(fromDay) || !/^\d{8}$/.test(toDay) || fromDay > toDay) {
  486. const err = new Error('日期区间不合法(需 YYYYMMDD,且开始 ≤ 结束)');
  487. err.code = 'SYNC_BAD_RANGE';
  488. throw err;
  489. }
  490. const jobs = [];
  491. if (scope === 'all' || scope === 'company') {
  492. jobs.push({ kind: 'company', label: '全公司', params: { chainId: '-1', shopListIn: '' }, fn: () => callHistory({ chainId: '-1', shopListIn: '' }, { scope: 'company', chainId: '-1', chainName: '' }) });
  493. }
  494. if (scope === 'all' || scope === 'chain') {
  495. for (const c of tree.chains) {
  496. jobs.push({
  497. kind: 'chain', label: `连锁·${c.chainName}`, params: { chainId: c.chainId, shopListIn: '' },
  498. fn: () => callHistory({ chainId: c.chainId, shopListIn: '' }, { scope: 'chain', chainId: c.chainId, chainName: c.chainName }),
  499. });
  500. }
  501. }
  502. if (scope === 'all' || scope === 'shop') {
  503. let shops = tree.shops;
  504. if (chainId) shops = shops.filter((s) => String(s.chainId) === String(chainId));
  505. if (Array.isArray(shopIds) && shopIds.length) {
  506. const wanted = new Set(shopIds.map(String));
  507. shops = shops.filter((s) => wanted.has(String(s.shopId)));
  508. }
  509. for (const s of shops) {
  510. jobs.push({
  511. kind: 'shop', label: `门店·${s.shopName}`, params: { chainId: '-1', shopListIn: String(s.shopId) },
  512. fn: () => callShopHistory(s, usedPort),
  513. });
  514. }
  515. }
  516. Object.assign(syncState, {
  517. entity: { key: entity.key, name: entity.name }, from: fromDay, to: toDay,
  518. planTotal: jobs.length, current: '',
  519. });
  520. const usedPort = Number(port || DEFAULT_CDP_PORT);
  521. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  522. for (const job of jobs) {
  523. if (syncState.cancelRequested) break;
  524. syncState.current = job.label;
  525. try {
  526. const rows = await job.fn(usedPort);
  527. const res = store.upsertRows(entity.key, rows);
  528. syncState.added += res.added;
  529. syncState.updated += res.updated;
  530. // 诊断信息:请求参数 + 返回数据概况,便于在「同步记录」里远程排查取数异常
  531. const nonZero = rows.filter((r) => (Number(r.validOrderCnt) || 0) > 0 || (Number(r.grossAmt) || 0) > 0).length;
  532. const step = {
  533. label: job.label, ok: true, added: res.added, updated: res.updated,
  534. rows: rows.length, nonZero,
  535. ordersSum: Math.round(rows.reduce((s, r) => s + (Number(r.validOrderCnt) || 0), 0) * 100) / 100,
  536. grossSum: Math.round(rows.reduce((s, r) => s + (Number(r.grossAmt) || 0), 0) * 100) / 100,
  537. params: job.params || null,
  538. };
  539. if (rows.length && nonZero === 0) step.warn = '返回全零,疑似取数参数或数据权限异常';
  540. syncState.steps.push(step);
  541. } catch (e) {
  542. syncState.failed += 1;
  543. syncState.steps.push({ label: job.label, ok: false, error: e.message || String(e) });
  544. }
  545. syncState.done += 1;
  546. await sleep(180); // 温和节奏,避免连续轰接口
  547. }
  548. const run = {
  549. at: new Date().toISOString(), from: fromDay, to: toDay, scope,
  550. cancelled: syncState.cancelRequested,
  551. added: syncState.added, updated: syncState.updated, failed: syncState.failed,
  552. done: syncState.done, total: syncState.planTotal,
  553. variantHits: syncState.variantHits || 0,
  554. steps: syncState.steps,
  555. };
  556. store.appendSyncLog(entity.key, run);
  557. syncState.error = null;
  558. } catch (e) {
  559. syncState.error = e.message || String(e);
  560. syncState.steps.push({ label: '准备', ok: false, error: syncState.error });
  561. } finally {
  562. syncState.running = false;
  563. syncState.current = '';
  564. syncState.finishedAt = new Date().toISOString();
  565. }
  566. return syncSnapshot();
  567. }
  568. /**
  569. * 调 historyOverview 取一个区间的逐日行并归一化。
  570. * scopeKey = { chainId, shopListIn };ctx 携带行的归属信息。
  571. * 返回:逐日行数组 + (若接口返回 ds='all' 汇总行)一条 scope='summary' 的范围汇总行。
  572. */
  573. async function callHistory(scopeKey, ctx, port = DEFAULT_CDP_PORT) {
  574. const o = {
  575. port,
  576. chainId: scopeKey.chainId,
  577. shopListIn: scopeKey.shopListIn || '',
  578. from: syncState.from,
  579. to: syncState.to,
  580. dateType: 'self_define',
  581. };
  582. const r = await kit.statsHistory(o);
  583. if (r.risk) throw new Error('触发客户端风控(请稍后在客户端完成验证后重试)');
  584. if (r.authFail) throw new Error('登录态失效(ksid 过期),请在商家客户端重新登录');
  585. // HTTP 层失败:pageFetch 拿不到业务数据,不检查会把失败伪装成 0 行落库
  586. if (r.ok === false) throw new Error('接口请求失败:' + (r.error || `HTTP ${r.status || '?'}`));
  587. // 形态守卫:有原始行但无任何 ds 形态行 = 接口/页面结构变化,拒绝静默落空
  588. if (r.shapeWarning) throw new Error(`接口返回形态异常:${(r.rows || []).length} 行均无 ds 字段(接口/页面结构可能已变化),已拒绝把空数据落库`);
  589. if (r.code != null && r.code !== 0 && String(r.code) !== '0') {
  590. throw new Error('接口返回异常 code=' + r.code + (r.msg ? ' ' + r.msg : ''));
  591. }
  592. const daily = (r.daily || []).map((d) => normalizeRow(d, ctx));
  593. if (r.all) {
  594. daily.push(normalizeSummaryRow(r.all, { ...ctx, date: syncState.to }));
  595. }
  596. return daily;
  597. }
  598. /** 门店行是否有真实数据(全零 = 取数异常形态或确实无营业) */
  599. function shopRowsHaveData(rows) {
  600. return (rows || []).some((r) => (Number(r.validOrderCnt) || 0) > 0 || (Number(r.grossAmt) || 0) > 0
  601. || (Number(r.incomeAmt) || 0) > 0 || (Number(r.expUv) || 0) > 0);
  602. }
  603. /** 门店级取数(参数自愈):现行 chainId=-1+shopListIn 在部分账号(尤其新开单校区账号)
  604. * 返回全零而接口侧数据确实存在。把取数诊断的 A/B/D 对照自动化进同步:
  605. * A 现行(-1) → B 该店真实连锁ID → D 空连锁ID,哪个变体取到真实数据就用哪个;
  606. * 三种都全零则按合法无营业返回首个结果(多花的两次请求量级可接受)。 */
  607. async function callShopHistory(s, port = DEFAULT_CDP_PORT) {
  608. const ctx = { scope: 'shop', shopId: s.shopId, shopName: s.shopName, chainId: s.chainId || '', chainName: s.chainName || '' };
  609. const variants = [
  610. { tag: 'A', scopeKey: { chainId: '-1', shopListIn: String(s.shopId) } },
  611. ...(s.chainId ? [{ tag: 'B', scopeKey: { chainId: String(s.chainId), shopListIn: String(s.shopId) } }] : []),
  612. { tag: 'D', scopeKey: { chainId: '', shopListIn: String(s.shopId) } },
  613. ];
  614. let fallback;
  615. for (const v of variants) {
  616. const rows = await callHistory(v.scopeKey, ctx, port);
  617. if (shopRowsHaveData(rows)) {
  618. if (v.tag !== 'A') syncState.variantHits = (syncState.variantHits || 0) + 1;
  619. return rows;
  620. }
  621. if (!fallback) fallback = rows;
  622. }
  623. return fallback;
  624. }
  625. function requestSyncCancel() {
  626. if (syncState.running) syncState.cancelRequested = true;
  627. return syncSnapshot();
  628. }
  629. // ---------- 查询 / 汇总 ----------
  630. const SUM_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt', 'actOrdCnt', 'actSubsidyAmt'];
  631. const RATE_FIELDS = ['clkExpRate', 'ordClkRate'];
  632. function summarizeRows(rows) {
  633. const s = { days: new Set(), shops: new Set() };
  634. for (const f of SUM_FIELDS) s[f] = 0;
  635. let rateWeight = { clkExpRate: 0, ordClkRate: 0 };
  636. let rateBase = { clkExpRate: 0, ordClkRate: 0 };
  637. for (const r of rows) {
  638. s.days.add(r.date);
  639. if (r.shopId) s.shops.add(r.shopId);
  640. for (const f of SUM_FIELDS) s[f] += Number(r[f]) || 0;
  641. if (Number(r.expUv) > 0) { rateWeight.clkExpRate += (Number(r.clkExpRate) || 0) * Number(r.expUv); rateBase.clkExpRate += Number(r.expUv); }
  642. if (Number(r.orderUserCnt) > 0) { rateWeight.ordClkRate += (Number(r.ordClkRate) || 0) * Number(r.orderUserCnt); rateBase.ordClkRate += Number(r.orderUserCnt); }
  643. }
  644. s.perActualPay = s.validOrderCnt > 0 ? +(s.grossAmt / s.validOrderCnt).toFixed(2) : 0;
  645. for (const f of SUM_FIELDS) s[f] = Math.round(s[f] * 100) / 100;
  646. for (const f of RATE_FIELDS) s[f] = rateBase[f] > 0 ? +(rateWeight[f] / rateBase[f]).toFixed(4) : 0;
  647. s.dayCount = s.days.size;
  648. s.shopCount = s.shops.size;
  649. delete s.days;
  650. delete s.shops;
  651. return s;
  652. }
  653. const BUSY_LEVEL_TEXT = {
  654. OPEN: '营业中', CLOSE: '休息', SUSPEND_BUSINESS: '暂停营业', INVALID: '未开业',
  655. };
  656. /** 报表行回填营业状态:lv=当日最新快照;open=当天任一时刻曾营业中(取「曾营业」) */
  657. function attachBusyStatus(key, rows) {
  658. let days;
  659. try { days = (store.loadBusyStatus(key).days) || {}; } catch { return; }
  660. for (const r of rows) {
  661. if (!r.shopId || r.scope !== 'shop') continue;
  662. const hit = days[r.date] && days[r.date][String(r.shopId)];
  663. if (!hit || !hit.lv) continue;
  664. r.busyLevel = hit.lv === 'OPEN' ? '营业中'
  665. : (hit.open ? '曾营业' : (BUSY_LEVEL_TEXT[hit.lv] || hit.lv));
  666. }
  667. }
  668. function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
  669. const key = store.sanitizeKey(entity);
  670. const meta = store.getEntity(key);
  671. let rows = store.queryRows(key, { from, to, scope });
  672. if (shopId) rows = rows.filter((r) => String(r.shopId) === String(shopId));
  673. if (Array.isArray(shops) && shops.length) {
  674. const wanted = new Set(shops.map(String));
  675. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  676. }
  677. if (q) {
  678. const needle = String(q).toLowerCase();
  679. rows = rows.filter((r) => String(r.shopName || '').toLowerCase().includes(needle));
  680. }
  681. const account = meta ? meta.name : key;
  682. rows = rows.map((r) => ({ ...r, account }));
  683. attachBusyStatus(key, rows);
  684. return {
  685. entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
  686. from: from || null, to: to || null,
  687. columns: REPORT_COLUMNS,
  688. summary: summarizeRows(rows),
  689. rows,
  690. };
  691. }
  692. /** BI 聚合视图:group = 'shop'(按门店)| 'date'(按日期) */
  693. function aggregateReport({ entity, from, to, group = 'shop', shops } = {}) {
  694. const key = store.sanitizeKey(entity);
  695. const meta = store.getEntity(key);
  696. let rows = store.queryRows(key, { from, to, scope: 'shop' });
  697. if (Array.isArray(shops) && shops.length) {
  698. const wanted = new Set(shops.map(String));
  699. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  700. }
  701. const buckets = new Map();
  702. for (const r of rows) {
  703. const k = group === 'date' ? r.date : String(r.shopId || '');
  704. if (!buckets.has(k)) buckets.set(k, []);
  705. buckets.get(k).push(r);
  706. }
  707. const out = [];
  708. for (const [k, list] of buckets) {
  709. const first = list[0] || {};
  710. const base = group === 'date'
  711. ? { date: k, shopName: `${list.length} 家门店`, shopId: '', scope: 'agg-date' }
  712. : { date: '', shopName: first.shopName || k, shopId: k, scope: 'agg-shop' };
  713. const s = summarizeRows(list);
  714. out.push({ ...base, ...s });
  715. }
  716. out.sort((a, b) => (group === 'date' ? (a.date < b.date ? -1 : 1) : (b.grossAmt || 0) - (a.grossAmt || 0)));
  717. const account = meta ? meta.name : key;
  718. return { columns: REPORT_COLUMNS, rows: out.map((r) => ({ ...r, account })), summary: summarizeRows(rows) };
  719. }
  720. // ---------- CSV 导出 ----------
  721. function csvEscape(v) {
  722. if (v == null) return '';
  723. let s = typeof v === 'object' ? JSON.stringify(v) : String(v);
  724. // 防 Excel 公式注入:非数字值以 = + - @ 开头时前置单引号(负数是数字,不受影响)
  725. if (/^[=+@\t\r]/.test(s) || (/^-/.test(s) && !Number.isFinite(Number(s)))) {
  726. s = "'" + s;
  727. }
  728. return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
  729. }
  730. function rowsToCsv(columns, rows) {
  731. const head = columns.map((c) => c.label);
  732. const lines = [head.join(',')];
  733. for (const r of rows) {
  734. const cells = columns.map((c) => {
  735. let v = r[c.key];
  736. if (v == null) v = '';
  737. if (c.type === 'pct') v = v === '' ? '' : (Number(v) * 100).toFixed(2) + '%';
  738. if (c.type === 'date' && /^\d{8}$/.test(String(v))) {
  739. const s = String(v);
  740. v = `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
  741. }
  742. return csvEscape(v);
  743. });
  744. lines.push(cells.join(','));
  745. }
  746. return '\ufeff' + lines.join('\r\n');
  747. }
  748. // ---------- 定向流量采集(接口目录补抓,配合取数诊断使用) ----------
  749. function startHarvest({ seconds } = {}) {
  750. return harvest.runHarvest({ port: DEFAULT_CDP_PORT, seconds });
  751. }
  752. function stopHarvest() { return harvest.stopHarvest(); }
  753. function harvestStatus() { return harvest.status(); }
  754. function getHarvestResult() { return harvest.getResult(); }
  755. module.exports = {
  756. DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
  757. naposStatus, fetchTree, runNaposDiagnose,
  758. runSync, syncSnapshot, requestSyncCancel,
  759. queryReport, aggregateReport, summarizeRows,
  760. rowsToCsv,
  761. startHarvest, stopHarvest, harvestStatus, getHarvestResult,
  762. store,
  763. };