|
@@ -188,6 +188,166 @@ async function naposStatus() {
|
|
|
return out;
|
|
return out;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ---------- 取数诊断(网页一键版 · 全程只读) ----------
|
|
|
|
|
+// 给「装了商家客户端」的电脑用:网页连点侧边栏版本号出现入口,一键跑门店级取数对照实验,
|
|
|
|
|
+// 报告在页面直接看 / 复制回开发侧。覆盖三类定位:
|
|
|
|
|
+// · 门店维度参数(现行 chainId=-1+shopListIn 是否有数,与 B/D 变体对照)
|
|
|
|
|
+// · 指标字段存在性(下单人数门店级是否缺失、营业时长/异常关店时长是否有源)
|
|
|
|
|
+// · 暂停营业门店清单(busyLevel 当前状态快照)
|
|
|
|
|
+
|
|
|
|
|
+let diagnoseRunning = false;
|
|
|
|
|
+
|
|
|
|
|
+const DIAGNOSE_CORE_METRICS = ['valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv',
|
|
|
|
|
+ 'order_user_cnt', 'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt',
|
|
|
|
|
+ 'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d'];
|
|
|
|
|
+
|
|
|
|
|
+function diagnoseMetricProbe(row) {
|
|
|
|
|
+ if (!row) return null;
|
|
|
|
|
+ const keys = Object.keys(row);
|
|
|
|
|
+ const coreMetrics = {};
|
|
|
|
|
+ for (const k of DIAGNOSE_CORE_METRICS) {
|
|
|
|
|
+ coreMetrics[k] = Object.prototype.hasOwnProperty.call(row, k) ? row[k] : '(字段不存在)';
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ fieldCount: keys.length,
|
|
|
|
|
+ coreMetrics,
|
|
|
|
|
+ relatedExtraKeys: keys
|
|
|
|
|
+ .filter((k) => /user|buyer|hour|close|open_time|biz|pause|rest/i.test(k))
|
|
|
|
|
+ .map((k) => `${k}=${JSON.stringify(row[k])}`),
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function runNaposDiagnose({ date, shopId } = {}) {
|
|
|
|
|
+ if (diagnoseRunning) {
|
|
|
|
|
+ const e = new Error('已有诊断在进行中,请稍候');
|
|
|
|
|
+ e.code = 'DIAGNOSE_BUSY'; e.status = 429;
|
|
|
|
|
+ throw e;
|
|
|
|
|
+ }
|
|
|
|
|
+ diagnoseRunning = true;
|
|
|
|
|
+ const t0 = Date.now();
|
|
|
|
|
+ const report = { generatedAt: new Date().toISOString(), port: DEFAULT_CDP_PORT };
|
|
|
|
|
+ try {
|
|
|
|
|
+ const st = await kit.status(DEFAULT_CDP_PORT);
|
|
|
|
|
+ report.status = { portOk: !!st.portOk, browser: st.browser || null, targets: (st.targets || []).length };
|
|
|
|
|
+ if (!st.portOk) {
|
|
|
|
|
+ report.conclusion = '客户端不在线:请完全退出商家客户端,用「启动.bat」附带调试端口的方式重启并登录后再诊断。';
|
|
|
|
|
+ return report;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
|
|
|
|
|
+ report.merchant = tree.merchant || null;
|
|
|
|
|
+ report.chains = (tree.chains || []).map((c) => ({ chainId: String(c.chainId), chainName: c.chainName }));
|
|
|
|
|
+ const shops = (tree.shops || []).map((s) => ({
|
|
|
|
|
+ shopId: String(s.shopId), shopName: s.shopName, chainId: String(s.chainId || ''), busyLevel: s.busyLevel || null,
|
|
|
|
|
+ }));
|
|
|
|
|
+ report.shopCount = shops.length;
|
|
|
|
|
+ report.busyLevelSummary = shops.reduce((a, s) => { const b = s.busyLevel || '(未知)'; a[b] = (a[b] || 0) + 1; return a; }, {});
|
|
|
|
|
+ report.pausedShops = shops.filter((s) => s.busyLevel === 'SUSPEND_BUSINESS' || s.busyLevel === 'INVALID');
|
|
|
|
|
+ if (!report.chains.length) {
|
|
|
|
|
+ report.conclusion = '组织树没有连锁信息,无法构造连锁对照实验。';
|
|
|
|
|
+ return report;
|
|
|
|
|
+ }
|
|
|
|
|
+ const chainId = report.chains[0].chainId;
|
|
|
|
|
+ const z = (n) => String(n).padStart(2, '0');
|
|
|
|
|
+ const ymdShift = (ymd, delta) => {
|
|
|
|
|
+ const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
|
|
|
|
|
+ d.setDate(d.getDate() + delta);
|
|
|
|
|
+ return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
|
|
|
|
|
+ };
|
|
|
|
|
+ const now = new Date();
|
|
|
|
|
+ const todayYmd = `${now.getFullYear()}${z(now.getMonth() + 1)}${z(now.getDate())}`;
|
|
|
|
|
+ const testDate = /^\d{8}$/.test(date || '') ? date : ymdShift(todayYmd, -1);
|
|
|
|
|
+ report.testDate = testDate;
|
|
|
|
|
+ const picked = [];
|
|
|
|
|
+ if (/^\d+$/.test(shopId || '')) {
|
|
|
|
|
+ const one = shops.find((s) => s.shopId === shopId);
|
|
|
|
|
+ if (one) picked.push(one);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const s of shops) {
|
|
|
|
|
+ if (picked.length >= 2) break;
|
|
|
|
|
+ if (!picked.includes(s)) picked.push(s);
|
|
|
|
|
+ }
|
|
|
|
|
+ report.testShops = picked.map((s) => ({ shopId: s.shopId, shopName: s.shopName, busyLevel: s.busyLevel || null }));
|
|
|
|
|
+
|
|
|
|
|
+ const sumOf = (rows, f) => Math.round((rows || []).reduce((s2, r) => s2 + (Number(r[f]) || 0), 0) * 100) / 100;
|
|
|
|
|
+ const runCase = async (tag, params) => {
|
|
|
|
|
+ const out = { tag, chainId: params.chainId, shopListIn: params.shopListIn, rows: 0, ordersSum: 0, grossSum: 0, expSum: 0, orderUserSum: 0 };
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await kit.statsHistory({ port: DEFAULT_CDP_PORT, from: params.from, to: params.to, dateType: 'self_define',
|
|
|
|
|
+ chainId: params.chainId, shopListIn: params.shopListIn });
|
|
|
|
|
+ const daily = r.daily || [];
|
|
|
|
|
+ out.rows = daily.length;
|
|
|
|
|
+ out.ordersSum = sumOf(daily, 'valid_order_cnt');
|
|
|
|
|
+ out.grossSum = sumOf(daily, 'gross_amt');
|
|
|
|
|
+ out.expSum = sumOf(daily, 'exp_uv');
|
|
|
|
|
+ out.orderUserSum = sumOf(daily, 'order_user_cnt');
|
|
|
|
|
+ out.firstRow = daily[0] ? (({ raw, ...rest }) => rest)(daily[0]) : null;
|
|
|
|
|
+ out.metricProbe = diagnoseMetricProbe(daily[0]);
|
|
|
|
|
+ out.ok = !!r.ok;
|
|
|
|
|
+ if (r.shapeWarning) out.shapeWarning = true;
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ out.error = e.message || String(e);
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
|
|
|
|
|
+ report.cases = [];
|
|
|
|
|
+ for (const s of picked) {
|
|
|
|
|
+ for (const c of [
|
|
|
|
|
+ { tag: 'A 现行 chainId=-1+shopListIn', chainId: '-1' },
|
|
|
|
|
+ { tag: `B 变体 chainId=${chainId}+shopListIn`, chainId },
|
|
|
|
|
+ { tag: 'D 变体 chainId=(空)+shopListIn', chainId: '' },
|
|
|
|
|
+ ]) {
|
|
|
|
|
+ const r = await runCase(c.tag, { from: testDate, to: testDate, chainId: c.chainId, shopListIn: s.shopId });
|
|
|
|
|
+ r.shopId = s.shopId;
|
|
|
|
|
+ r.shopName = s.shopName;
|
|
|
|
|
+ report.cases.push(r);
|
|
|
|
|
+ await new Promise((res) => setTimeout(res, 600));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const chainKeys = new Set(Object.keys(report.chainControl.firstRow || {}));
|
|
|
|
|
+ for (const c of report.cases) {
|
|
|
|
|
+ if (!c.firstRow) continue;
|
|
|
|
|
+ c.missingVsChain = [...chainKeys].filter((k) => !(k in c.firstRow));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 结论:把开发侧关心的判定直接写成人话
|
|
|
|
|
+ const lines = [];
|
|
|
|
|
+ const cc = report.chainControl;
|
|
|
|
|
+ if (!cc || !cc.rows || !cc.ordersSum) {
|
|
|
|
|
+ lines.push('连锁对照查询也为零:该日期可能无经营数据,换一个链级行有数的日期再诊断一次。');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lines.push(`连锁对照正常(${cc.rows} 行 / 有效订单 ${cc.ordersSum} / 营业额 ${cc.grossSum})。`);
|
|
|
|
|
+ const hit = report.cases.filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
|
|
|
|
|
+ if (hit.length) {
|
|
|
|
|
+ lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}——把本报告发回开发侧即可修正取数参数。`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lines.push('所有参数形式下门店维度均为零:需核对数据权限/门店经营状态(详见各变体原始返回首行)。');
|
|
|
|
|
+ }
|
|
|
|
|
+ const probeSrc = hit[0] || report.cases.find((c) => c.metricProbe);
|
|
|
|
|
+ const probe = probeSrc && probeSrc.metricProbe;
|
|
|
|
|
+ if (probe) {
|
|
|
|
|
+ const ouc = probe.coreMetrics.order_user_cnt;
|
|
|
|
|
+ lines.push(ouc === '(字段不存在)'
|
|
|
|
|
+ ? '门店级返回中「下单人数(order_user_cnt)」字段不存在——上游门店粒度不提供该指标,报表该列将恒为 0。'
|
|
|
|
|
+ : `门店级「下单人数(order_user_cnt)」字段存在,样本值 ${JSON.stringify(ouc)}。`);
|
|
|
|
|
+ }
|
|
|
|
|
+ const hourKeys = [cc, ...report.cases]
|
|
|
|
|
+ .flatMap((c) => (c.metricProbe && c.metricProbe.relatedExtraKeys) || []);
|
|
|
|
|
+ lines.push(hourKeys.length
|
|
|
|
|
+ ? `原始返回中与时长/关店相关的键:${[...new Set(hourKeys)].slice(0, 8).join(';')}`
|
|
|
|
|
+ : '原始返回中没有任何营业时长/异常关店相关字段——这两列为预留列,上游接口暂不提供。');
|
|
|
|
|
+ }
|
|
|
|
|
+ report.conclusionLines = lines;
|
|
|
|
|
+ report.conclusion = lines.join(' ');
|
|
|
|
|
+ return report;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ report.durationMs = Date.now() - t0;
|
|
|
|
|
+ diagnoseRunning = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ---------- 主体结构(多主体 tab 的来源) ----------
|
|
// ---------- 主体结构(多主体 tab 的来源) ----------
|
|
|
|
|
|
|
|
let treeCache = { at: 0, tree: null };
|
|
let treeCache = { at: 0, tree: null };
|
|
@@ -519,7 +679,7 @@ function rowsToCsv(columns, rows) {
|
|
|
|
|
|
|
|
module.exports = {
|
|
module.exports = {
|
|
|
DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
|
|
DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
|
|
|
- naposStatus, fetchTree,
|
|
|
|
|
|
|
+ naposStatus, fetchTree, runNaposDiagnose,
|
|
|
runSync, syncSnapshot, requestSyncCancel,
|
|
runSync, syncSnapshot, requestSyncCancel,
|
|
|
queryReport, aggregateReport, summarizeRows,
|
|
queryReport, aggregateReport, summarizeRows,
|
|
|
rowsToCsv,
|
|
rowsToCsv,
|