|
@@ -19,6 +19,7 @@
|
|
|
|
|
|
|
|
const kit = require('./napos/kit.js');
|
|
const kit = require('./napos/kit.js');
|
|
|
const store = require('./reports-store.js');
|
|
const store = require('./reports-store.js');
|
|
|
|
|
+const harvest = require('./napos/harvest.js');
|
|
|
|
|
|
|
|
const DEFAULT_CDP_PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
|
|
const DEFAULT_CDP_PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
|
|
|
|
|
|
|
@@ -41,6 +42,10 @@ const REPORT_COLUMNS = [
|
|
|
{ key: 'abnormalCloseHours', label: '异常关店时长(h)', type: 'hours', reserved: true,
|
|
{ key: 'abnormalCloseHours', label: '异常关店时长(h)', type: 'hours', reserved: true,
|
|
|
synonyms: ['异常关店', '异常关店时长', '关店时长', '异常闭店'],
|
|
synonyms: ['异常关店', '异常关店时长', '关店时长', '异常闭店'],
|
|
|
description: '预留列:非计划性关店时长,数据源暂未接入' },
|
|
description: '预留列:非计划性关店时长,数据源暂未接入' },
|
|
|
|
|
+ { key: 'busyLevel', label: '是否营业', type: 'text', aggregable: false,
|
|
|
|
|
+ synonyms: ['是否营业', '营业状态', '营业中', '开门没', '开店没', '营业没'],
|
|
|
|
|
+ description: '同步时客户端回报的门店营业状态快照:营业中 / 曾营业(当日曾开业,查询时已打烊)/ 休息 / 暂停营业 / 未开业(无效门店);'
|
|
|
|
|
+ + '仅反映同步当天起的状态,更早的历史日期无记录' },
|
|
|
{ key: 'validOrderCnt', label: '有效订单', type: 'int',
|
|
{ key: 'validOrderCnt', label: '有效订单', type: 'int',
|
|
|
synonyms: ['有效订单', '订单量', '单量', '订单数', '有效单量'],
|
|
synonyms: ['有效订单', '订单量', '单量', '订单数', '有效单量'],
|
|
|
description: '剔除取消/无效后的订单量' },
|
|
description: '剔除取消/无效后的订单量' },
|
|
@@ -304,6 +309,13 @@ async function runNaposDiagnose({ date, shopId } = {}) {
|
|
|
out.metricProbe = diagnoseMetricProbe(daily[0]);
|
|
out.metricProbe = diagnoseMetricProbe(daily[0]);
|
|
|
out.ok = !!r.ok;
|
|
out.ok = !!r.ok;
|
|
|
if (r.shapeWarning) out.shapeWarning = true;
|
|
if (r.shapeWarning) out.shapeWarning = true;
|
|
|
|
|
+ // 传输层证据:区分「请求没成功(未登录/风控/HTTP错误)」和「成功但没数据」——
|
|
|
|
|
+ // 此前这两种在报告里都长成 0 行,KNCT666 类问题因此被误读成"当天无经营数据"
|
|
|
|
|
+ out.status = r.status != null ? r.status : null;
|
|
|
|
|
+ if (r.risk) out.risk = true;
|
|
|
|
|
+ if (r.authFail) out.authFail = true;
|
|
|
|
|
+ if (r.code != null) out.code = r.code;
|
|
|
|
|
+ if (r.msg) out.msg = r.msg;
|
|
|
} catch (e) {
|
|
} catch (e) {
|
|
|
out.error = e.message || String(e);
|
|
out.error = e.message || String(e);
|
|
|
}
|
|
}
|
|
@@ -311,6 +323,21 @@ async function runNaposDiagnose({ date, shopId } = {}) {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
|
|
report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
|
|
|
|
|
+ // 连锁级查不到数时追加三组对照,一次跑完即可区分「请求失败 / 当天无数据 / 账号范围或权限」:
|
|
|
|
|
+ // E 账号默认范围(-1) F 账号根ID当连锁(UNIT 型账号的树根不是 CHAIN) G 近7天窗口(排除单日无数据)
|
|
|
|
|
+ report.chainFallbacks = [];
|
|
|
|
|
+ if (!report.chainControl.rows || !report.chainControl.ordersSum) {
|
|
|
|
|
+ const fallbacks = [
|
|
|
|
|
+ { tag: 'E 对照 chainId=-1·不带门店', params: { from: testDate, to: testDate, chainId: '-1', shopListIn: '' } },
|
|
|
|
|
+ { tag: `F 对照 chainId=${report.merchant && report.merchant.id}·账号根·不带门店`, params: { from: testDate, to: testDate, chainId: String((report.merchant && report.merchant.id) || ''), shopListIn: '' } },
|
|
|
|
|
+ { tag: `G 对照 近7天·chainId=${chainId}`, params: { from: ymdShift(testDate, -6), to: testDate, chainId, shopListIn: '' } },
|
|
|
|
|
+ ];
|
|
|
|
|
+ for (const f of fallbacks) {
|
|
|
|
|
+ const r = await runCase(f.tag, f.params);
|
|
|
|
|
+ report.chainFallbacks.push(r);
|
|
|
|
|
+ await new Promise((res) => setTimeout(res, 600));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
report.cases = [];
|
|
report.cases = [];
|
|
|
for (const s of picked) {
|
|
for (const s of picked) {
|
|
|
for (const c of [
|
|
for (const c of [
|
|
@@ -331,16 +358,34 @@ async function runNaposDiagnose({ date, shopId } = {}) {
|
|
|
c.missingVsChain = [...chainKeys].filter((k) => !(k in c.firstRow));
|
|
c.missingVsChain = [...chainKeys].filter((k) => !(k in c.firstRow));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 结论:把开发侧关心的判定直接写成人话
|
|
|
|
|
|
|
+ // 结论:把判定直接写成人话,并区分「请求失败」和「真没数据」
|
|
|
const lines = [];
|
|
const lines = [];
|
|
|
const cc = report.chainControl;
|
|
const cc = report.chainControl;
|
|
|
|
|
+ const isBadStatus = (c) => c && c.status != null && (c.status < 200 || c.status >= 300);
|
|
|
|
|
+ const transportFail = (c) => !!c && !!(c.error || c.authFail || c.risk || isBadStatus(c)
|
|
|
|
|
+ || (c.code != null && String(c.code) !== '0'));
|
|
|
|
|
+ const transportReason = (c) => [c.error && '异常', c.authFail && '未登录', c.risk && '风控',
|
|
|
|
|
+ isBadStatus(c) && `HTTP ${c.status}`, c.code != null && String(c.code) !== '0' && `code=${c.code}`,
|
|
|
|
|
+ c.msg].filter(Boolean).join('/');
|
|
|
if (!cc || !cc.rows || !cc.ordersSum) {
|
|
if (!cc || !cc.rows || !cc.ordersSum) {
|
|
|
- lines.push('连锁对照查询也为零:该日期可能无经营数据,换一个链级行有数的日期再诊断一次。');
|
|
|
|
|
|
|
+ const bad = [cc, ...(report.chainFallbacks || [])].filter(transportFail);
|
|
|
|
|
+ if (bad.length) {
|
|
|
|
|
+ lines.push(`数据中心(生意参谋)接口请求没有成功(${[...new Set(bad.map(transportReason))].slice(0, 3).join(';')})——这不是「没数据」。`
|
|
|
|
|
+ + '多半是这个账号的数据中台登录态没恢复:请在客户端里点开一次「数据中心」页面,仍不行就完全退出客户端、带调试端口重启并重新登录,再诊断一次。');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const week = (report.chainFallbacks || []).find((c) => String(c.tag).startsWith('G') && c.rows && c.ordersSum);
|
|
|
|
|
+ if (week) {
|
|
|
|
|
+ lines.push(`查询当天(${report.testDate})确实没有数据,但近 7 天有数(有效订单 ${week.ordersSum})——参数与权限都正常,直接重新同步该主体即可取到数。`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lines.push('各参数形式下连锁级均为零:该日期可能无经营数据,或该账号在数据中心无此范围的数据权限。可换一个确定有数的日期再诊断;若客户端「数据中心」页面里也看不到数,需平台侧确认权限。');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
lines.push(`连锁对照正常(${cc.rows} 行 / 有效订单 ${cc.ordersSum} / 营业额 ${cc.grossSum})。`);
|
|
lines.push(`连锁对照正常(${cc.rows} 行 / 有效订单 ${cc.ordersSum} / 营业额 ${cc.grossSum})。`);
|
|
|
const hit = report.cases.filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
|
|
const hit = report.cases.filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
|
|
|
if (hit.length) {
|
|
if (hit.length) {
|
|
|
- lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}——把本报告发回开发侧即可修正取数参数。`);
|
|
|
|
|
|
|
+ lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}。`
|
|
|
|
|
+ + '同步已内置参数自动切换——把程序更新到最新版后,对该主体重新同步一次即可修正,无需人工配置。');
|
|
|
} else {
|
|
} else {
|
|
|
lines.push('所有参数形式下门店维度均为零:需核对数据权限/门店经营状态(详见各变体原始返回首行)。');
|
|
lines.push('所有参数形式下门店维度均为零:需核对数据权限/门店经营状态(详见各变体原始返回首行)。');
|
|
|
}
|
|
}
|
|
@@ -460,6 +505,14 @@ async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {})
|
|
|
try {
|
|
try {
|
|
|
const tree = await fetchTree();
|
|
const tree = await fetchTree();
|
|
|
const entity = tree.entity;
|
|
const entity = tree.entity;
|
|
|
|
|
+ // 营业状态快照:busyLevel 反映的是「现在」,挂在同步发生的当天;失败不阻塞同步
|
|
|
|
|
+ try {
|
|
|
|
|
+ const lvShops = (tree.shops || []).map((s) => ({
|
|
|
|
|
+ shopId: String(s.shopId), shopName: s.shopName,
|
|
|
|
|
+ chainId: s.chainId ? String(s.chainId) : '', busyLevel: s.busyLevel || null,
|
|
|
|
|
+ }));
|
|
|
|
|
+ if (lvShops.length) store.saveBusyStatus(entity.key, kit.today(), lvShops);
|
|
|
|
|
+ } catch { /* 快照失败不影响取数 */ }
|
|
|
const fromDay = kit.normDay(from || dayShift(yesterday(), -29));
|
|
const fromDay = kit.normDay(from || dayShift(yesterday(), -29));
|
|
|
const toDay = kit.normDay(to || yesterday());
|
|
const toDay = kit.normDay(to || yesterday());
|
|
|
if (!/^\d{8}$/.test(fromDay) || !/^\d{8}$/.test(toDay) || fromDay > toDay) {
|
|
if (!/^\d{8}$/.test(fromDay) || !/^\d{8}$/.test(toDay) || fromDay > toDay) {
|
|
@@ -642,6 +695,23 @@ function summarizeRows(rows) {
|
|
|
return s;
|
|
return s;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const BUSY_LEVEL_TEXT = {
|
|
|
|
|
+ OPEN: '营业中', CLOSE: '休息', SUSPEND_BUSINESS: '暂停营业', INVALID: '未开业',
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 报表行回填营业状态:lv=当日最新快照;open=当天任一时刻曾营业中(取「曾营业」) */
|
|
|
|
|
+function attachBusyStatus(key, rows) {
|
|
|
|
|
+ let days;
|
|
|
|
|
+ try { days = (store.loadBusyStatus(key).days) || {}; } catch { return; }
|
|
|
|
|
+ for (const r of rows) {
|
|
|
|
|
+ if (!r.shopId || r.scope !== 'shop') continue;
|
|
|
|
|
+ const hit = days[r.date] && days[r.date][String(r.shopId)];
|
|
|
|
|
+ if (!hit || !hit.lv) continue;
|
|
|
|
|
+ r.busyLevel = hit.lv === 'OPEN' ? '营业中'
|
|
|
|
|
+ : (hit.open ? '曾营业' : (BUSY_LEVEL_TEXT[hit.lv] || hit.lv));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
|
|
function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
|
|
|
const key = store.sanitizeKey(entity);
|
|
const key = store.sanitizeKey(entity);
|
|
|
const meta = store.getEntity(key);
|
|
const meta = store.getEntity(key);
|
|
@@ -657,6 +727,7 @@ function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
|
|
|
}
|
|
}
|
|
|
const account = meta ? meta.name : key;
|
|
const account = meta ? meta.name : key;
|
|
|
rows = rows.map((r) => ({ ...r, account }));
|
|
rows = rows.map((r) => ({ ...r, account }));
|
|
|
|
|
+ attachBusyStatus(key, rows);
|
|
|
return {
|
|
return {
|
|
|
entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
|
|
entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
|
|
|
from: from || null, to: to || null,
|
|
from: from || null, to: to || null,
|
|
@@ -726,11 +797,21 @@ function rowsToCsv(columns, rows) {
|
|
|
return '\ufeff' + lines.join('\r\n');
|
|
return '\ufeff' + lines.join('\r\n');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ---------- 定向流量采集(接口目录补抓,配合取数诊断使用) ----------
|
|
|
|
|
+
|
|
|
|
|
+function startHarvest({ seconds } = {}) {
|
|
|
|
|
+ return harvest.runHarvest({ port: DEFAULT_CDP_PORT, seconds });
|
|
|
|
|
+}
|
|
|
|
|
+function stopHarvest() { return harvest.stopHarvest(); }
|
|
|
|
|
+function harvestStatus() { return harvest.status(); }
|
|
|
|
|
+function getHarvestResult() { return harvest.getResult(); }
|
|
|
|
|
+
|
|
|
module.exports = {
|
|
module.exports = {
|
|
|
DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
|
|
DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
|
|
|
naposStatus, fetchTree, runNaposDiagnose,
|
|
naposStatus, fetchTree, runNaposDiagnose,
|
|
|
runSync, syncSnapshot, requestSyncCancel,
|
|
runSync, syncSnapshot, requestSyncCancel,
|
|
|
queryReport, aggregateReport, summarizeRows,
|
|
queryReport, aggregateReport, summarizeRows,
|
|
|
rowsToCsv,
|
|
rowsToCsv,
|
|
|
|
|
+ startHarvest, stopHarvest, harvestStatus, getHarvestResult,
|
|
|
store,
|
|
store,
|
|
|
};
|
|
};
|