napos-service.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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 DEFAULT_CDP_PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
  21. // ---------- BI 列注册表(语义层)----------
  22. // synonyms / description 供「智能问数」的 LLM 做实体对齐与口径解释
  23. const REPORT_COLUMNS = [
  24. { key: 'date', label: '日期', type: 'date', locked: true, aggregable: false,
  25. synonyms: ['日期', '天', '哪天', '日期时间'],
  26. description: '数据日期,格式 YYYYMMDD;当天数据未结算,有效区间一般为 T-1 及更早' },
  27. { key: 'shopName', label: '门店名称', type: 'text', locked: true, aggregable: false,
  28. synonyms: ['门店', '店铺', '店', '门店名'],
  29. description: '门店名称,支持模糊筛选' },
  30. { key: 'bizHours', label: '营业时长(h)', type: 'hours', reserved: true,
  31. synonyms: ['营业时长', '营业时间', '开店时长'],
  32. description: '预留列:当日营业小时数,数据源暂未接入' },
  33. { key: 'abnormalCloseHours', label: '异常关店时长(h)', type: 'hours', reserved: true,
  34. synonyms: ['异常关店', '异常关店时长', '关店时长', '异常闭店'],
  35. description: '预留列:非计划性关店时长,数据源暂未接入' },
  36. { key: 'validOrderCnt', label: '有效订单', type: 'int',
  37. synonyms: ['有效订单', '订单量', '单量', '订单数', '有效单量'],
  38. description: '剔除取消/无效后的订单量' },
  39. { key: 'incomeAmt', label: '收入(元)', type: 'money',
  40. synonyms: ['收入', '实际收入', '到账', '营收'],
  41. description: '商家实际收入(元);营业额 = 收入 + 支出' },
  42. { key: 'grossAmt', label: '营业额(元)', type: 'money',
  43. synonyms: ['营业额', '流水', 'GMV', 'gmv', '销售额', '交易额'],
  44. description: '营业额(元),口径 = 收入 + 支出;做排行/对比的首选指标' },
  45. { key: 'goodsSaleAmt', label: '商品销售额(元)', type: 'money', reserved: true,
  46. synonyms: ['商品销售额', '商品销售', '菜品销售额'],
  47. description: '预留列:仅商品部分的销售金额,数据源暂未接入' },
  48. { key: 'expUv', label: '曝光人数', type: 'int',
  49. synonyms: ['曝光', '曝光人数', '曝光量', '展现人数'],
  50. description: '看到店铺的去重人数' },
  51. { key: 'orderUserCnt', label: '下单人数', type: 'int',
  52. synonyms: ['下单人数', '下单用户', '购买人数', '下单用户数'],
  53. description: '当日下单的去重用户数' },
  54. { key: 'clkExpRate', label: '进店转化率', type: 'pct',
  55. synonyms: ['进店转化率', '进店率', '点击率', '入店转化率'],
  56. description: '进店人数 / 曝光人数;≥9% 健康,<6% 偏低' },
  57. { key: 'ordClkRate', label: '下单转化率', type: 'pct',
  58. synonyms: ['下单转化率', '下单率', '成交转化率', '访购率'],
  59. description: '下单人数 / 进店人数;≥2.5% 健康,<2% 偏低' },
  60. { key: 'perActualPay', label: '客单价(元)', type: 'money',
  61. synonyms: ['客单价', '均价', '笔单价'],
  62. description: '营业额 / 有效订单(元)' },
  63. { key: 'expenseAmt', label: '支出(元)', type: 'money',
  64. synonyms: ['支出', '费用', '成本', '平台扣费'],
  65. description: '支出(元),含平台佣金、推广费用等' },
  66. { key: 'actOrdCnt', label: '活动订单', type: 'int',
  67. synonyms: ['活动订单', '活动单量', '营销订单'],
  68. description: '参与营销活动的订单量' },
  69. { key: 'actSubsidyAmt', label: '活动补贴(元)', type: 'money',
  70. synonyms: ['活动补贴', '补贴金额', '商家补贴'],
  71. description: '活动中商家承担的补贴金额(元)' },
  72. { key: 'shopRoi', label: '推广ROI', type: 'num',
  73. synonyms: ['ROI', 'roi', '投产比', '推广roi'],
  74. description: '推广投入产出比;大盘口径常为 0' },
  75. { key: 'rebuyRate30d', label: '30日复购率', type: 'pct',
  76. synonyms: ['复购率', '30日复购', '老客回购率'],
  77. description: '近 30 天老客复购比例' },
  78. ];
  79. const COLUMN_MAP = Object.fromEntries(REPORT_COLUMNS.map((c) => [c.key, c]));
  80. // ---------- 原始字段 → 标准行 ----------
  81. const FIELD_MAP = {
  82. valid_order_cnt: 'validOrderCnt',
  83. gross_amt: 'grossAmt',
  84. income_amt: 'incomeAmt',
  85. expense_amt: 'expenseAmt',
  86. exp_uv: 'expUv',
  87. order_user_cnt: 'orderUserCnt',
  88. clk_exp_rate: 'clkExpRate',
  89. ord_clk_rate: 'ordClkRate',
  90. per_actual_pay: 'perActualPay',
  91. act_ord_cnt: 'actOrdCnt',
  92. act_shop_subsidy_amt: 'actSubsidyAmt',
  93. shop_roi: 'shopRoi',
  94. rebuy_rate_30d: 'rebuyRate30d',
  95. };
  96. function trimRaw(raw) {
  97. const out = {};
  98. for (const [k, v] of Object.entries(raw || {})) {
  99. if (k === 'ds') continue;
  100. if (/_diff$|_mall_avg$|_mall_avg_rank$/.test(k)) continue;
  101. out[k] = v;
  102. }
  103. return out;
  104. }
  105. function normalizeRow(rawDaily, ctx) {
  106. const row = {
  107. date: String(rawDaily.ds || ''),
  108. scope: ctx.scope,
  109. shopId: ctx.shopId == null ? '' : String(ctx.shopId),
  110. shopName: ctx.shopName || '',
  111. chainId: ctx.chainId == null ? '' : String(ctx.chainId),
  112. chainName: ctx.chainName || '',
  113. bizHours: null,
  114. abnormalCloseHours: null,
  115. goodsSaleAmt: null,
  116. syncedAt: new Date().toISOString(),
  117. raw: trimRaw(rawDaily),
  118. };
  119. for (const [src, dst] of Object.entries(FIELD_MAP)) {
  120. const v = rawDaily[src];
  121. row[dst] = v == null || v === '' ? 0 : Number(v);
  122. }
  123. return row;
  124. }
  125. // ---------- 连接状态 ----------
  126. /** 按平台给出「以调试端口启动商家客户端」的可执行指引 */
  127. function cdpLaunchHint(port = DEFAULT_CDP_PORT) {
  128. if (process.platform === 'win32') {
  129. return `Windows 启动方法:① 任务栏右键完全退出淘宝闪购客户端(任务管理器确认进程已退出);`
  130. + `② 右键桌面快捷方式 → 属性 → 在「目标」末尾追加一个空格和 --remote-debugging-port=${port} → 确定后用该快捷方式启动。`
  131. + `注意:客户端每次重启都要用这个快捷方式,否则端口不会开启。`;
  132. }
  133. return `macOS 启动方法:① 完全退出客户端(⌘Q,或活动监视器确认进程退出);`
  134. + `② 终端执行:open -a "淘宝闪购商家版" --args --remote-debugging-port=${port}`
  135. + `(应用名以实际为准;也可直接运行 "/Applications/客户端.app/Contents/MacOS/可执行文件" --remote-debugging-port=${port})。`
  136. + `注意:客户端每次重启都要带此参数。`;
  137. }
  138. async function naposStatus() {
  139. const out = { port: DEFAULT_CDP_PORT, connected: false };
  140. try {
  141. const st = await kit.status(DEFAULT_CDP_PORT);
  142. out.connected = !!st.portOk;
  143. out.browser = st.browser || null;
  144. out.targets = (st.targets || []).length;
  145. out.contexts = (st.contexts || []).length;
  146. if (st.probeError) out.probeError = String(st.probeError);
  147. } catch (e) {
  148. out.error = e.message || String(e);
  149. }
  150. if (!out.connected) out.hint = cdpLaunchHint();
  151. // 自检:本机能否连通调试端口(给用户的浏览器直测地址)
  152. out.selfCheckUrl = `http://127.0.0.1:${DEFAULT_CDP_PORT}/json/version`;
  153. return out;
  154. }
  155. // ---------- 主体结构(多主体 tab 的来源) ----------
  156. let treeCache = { at: 0, tree: null };
  157. async function fetchTree({ refresh = false } = {}) {
  158. if (!refresh && treeCache.tree && Date.now() - treeCache.at < 5 * 60 * 1000) {
  159. return treeCache.tree;
  160. }
  161. const tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
  162. if (!tree.ok) {
  163. const err = new Error('读取门店组织树失败:' + (tree.code || tree.status || '未知错误'));
  164. err.code = 'NAPOS_TREE_FAIL';
  165. throw err;
  166. }
  167. const entity = store.upsertEntity({
  168. merchantId: (tree.merchant && tree.merchant.id) || 'default',
  169. merchantName: (tree.merchant && tree.merchant.name) || '默认主体',
  170. chains: tree.chains.map((c) => ({ chainId: String(c.chainId), chainName: c.chainName })),
  171. shops: tree.shops.map((s) => ({
  172. shopId: String(s.shopId), shopName: s.shopName,
  173. chainId: s.chainId ? String(s.chainId) : '', chainName: s.chainName || '',
  174. busyLevel: s.busyLevel || null,
  175. })),
  176. }).entity;
  177. // 缓存必须存补齐 entity 后的完整对象:缓存的消费方(runSync / tree 路由)都依赖 tree.entity
  178. const enriched = { ...tree, entity };
  179. treeCache = { at: Date.now(), tree: enriched };
  180. return enriched;
  181. }
  182. // ---------- 同步任务 ----------
  183. const syncState = {
  184. running: false,
  185. cancelRequested: false,
  186. startedAt: null,
  187. finishedAt: null,
  188. error: null,
  189. entity: null,
  190. from: null,
  191. to: null,
  192. planTotal: 0,
  193. done: 0,
  194. current: '',
  195. added: 0,
  196. updated: 0,
  197. failed: 0,
  198. steps: [],
  199. };
  200. function syncSnapshot() {
  201. const { running, cancelRequested, startedAt, finishedAt, error, entity, from, to,
  202. planTotal, done, current, added, updated, failed } = syncState;
  203. return { running, cancelRequested, startedAt, finishedAt, error,
  204. entity, from, to, planTotal, done, current, added, updated, failed };
  205. }
  206. function dayShift(ymd, delta) {
  207. const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  208. d.setDate(d.getDate() + delta);
  209. const z = (n) => String(n).padStart(2, '0');
  210. return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
  211. }
  212. function yesterday() { return dayShift(kit.today(), -1); }
  213. /**
  214. * 同步一个区间到本地数据湖。
  215. * scope:
  216. * 'company' — 仅全公司聚合行
  217. * 'chain' — 各连锁聚合行
  218. * 'shop' — 逐店明细行(可选 chainId 限定 / shopIds 限定)
  219. * 'all' — 以上全部(默认)
  220. */
  221. async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {}) {
  222. if (syncState.running) {
  223. const err = new Error('已有同步任务在进行中');
  224. err.code = 'SYNC_BUSY';
  225. throw err;
  226. }
  227. // 先同步置位,避免忙碌检查与实际启动之间因 await 出现并发窗口
  228. Object.assign(syncState, {
  229. running: true, cancelRequested: false, startedAt: new Date().toISOString(), finishedAt: null,
  230. error: null, entity: null, from: null, to: null,
  231. planTotal: 0, done: 0, current: '准备中', added: 0, updated: 0, failed: 0, steps: [],
  232. });
  233. try {
  234. const tree = await fetchTree();
  235. const entity = tree.entity;
  236. const fromDay = kit.normDay(from || dayShift(yesterday(), -29));
  237. const toDay = kit.normDay(to || yesterday());
  238. if (!/^\d{8}$/.test(fromDay) || !/^\d{8}$/.test(toDay) || fromDay > toDay) {
  239. const err = new Error('日期区间不合法(需 YYYYMMDD,且开始 ≤ 结束)');
  240. err.code = 'SYNC_BAD_RANGE';
  241. throw err;
  242. }
  243. const jobs = [];
  244. if (scope === 'all' || scope === 'company') {
  245. jobs.push({ kind: 'company', label: '全公司', fn: () => callHistory({ chainId: '-1', shopListIn: '' }, { scope: 'company', chainId: '-1', chainName: '' }) });
  246. }
  247. if (scope === 'all' || scope === 'chain') {
  248. for (const c of tree.chains) {
  249. jobs.push({
  250. kind: 'chain', label: `连锁·${c.chainName}`,
  251. fn: () => callHistory({ chainId: c.chainId, shopListIn: '' }, { scope: 'chain', chainId: c.chainId, chainName: c.chainName }),
  252. });
  253. }
  254. }
  255. if (scope === 'all' || scope === 'shop') {
  256. let shops = tree.shops;
  257. if (chainId) shops = shops.filter((s) => String(s.chainId) === String(chainId));
  258. if (Array.isArray(shopIds) && shopIds.length) {
  259. const wanted = new Set(shopIds.map(String));
  260. shops = shops.filter((s) => wanted.has(String(s.shopId)));
  261. }
  262. for (const s of shops) {
  263. jobs.push({
  264. kind: 'shop', label: `门店·${s.shopName}`,
  265. fn: () => callHistory(
  266. { chainId: '-1', shopListIn: String(s.shopId) },
  267. { scope: 'shop', shopId: s.shopId, shopName: s.shopName, chainId: s.chainId || '', chainName: s.chainName || '' },
  268. ),
  269. });
  270. }
  271. }
  272. Object.assign(syncState, {
  273. entity: { key: entity.key, name: entity.name }, from: fromDay, to: toDay,
  274. planTotal: jobs.length, current: '',
  275. });
  276. const usedPort = Number(port || DEFAULT_CDP_PORT);
  277. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  278. for (const job of jobs) {
  279. if (syncState.cancelRequested) break;
  280. syncState.current = job.label;
  281. try {
  282. const rows = await job.fn(usedPort);
  283. const res = store.upsertRows(entity.key, rows);
  284. syncState.added += res.added;
  285. syncState.updated += res.updated;
  286. syncState.steps.push({ label: job.label, ok: true, added: res.added, updated: res.updated });
  287. } catch (e) {
  288. syncState.failed += 1;
  289. syncState.steps.push({ label: job.label, ok: false, error: e.message || String(e) });
  290. }
  291. syncState.done += 1;
  292. await sleep(180); // 温和节奏,避免连续轰接口
  293. }
  294. const run = {
  295. at: new Date().toISOString(), from: fromDay, to: toDay, scope,
  296. cancelled: syncState.cancelRequested,
  297. added: syncState.added, updated: syncState.updated, failed: syncState.failed,
  298. done: syncState.done, total: syncState.planTotal,
  299. steps: syncState.steps,
  300. };
  301. store.appendSyncLog(entity.key, run);
  302. syncState.error = null;
  303. } catch (e) {
  304. syncState.error = e.message || String(e);
  305. syncState.steps.push({ label: '准备', ok: false, error: syncState.error });
  306. } finally {
  307. syncState.running = false;
  308. syncState.current = '';
  309. syncState.finishedAt = new Date().toISOString();
  310. }
  311. return syncSnapshot();
  312. }
  313. /**
  314. * 调 historyOverview 取一个区间的逐日行并归一化。
  315. * scopeKey = { chainId, shopListIn };ctx 携带行的归属信息。
  316. */
  317. async function callHistory(scopeKey, ctx, port = DEFAULT_CDP_PORT) {
  318. const o = {
  319. port,
  320. chainId: scopeKey.chainId,
  321. shopListIn: scopeKey.shopListIn || '',
  322. from: syncState.from,
  323. to: syncState.to,
  324. dateType: 'self_define',
  325. };
  326. const r = await kit.statsHistory(o);
  327. if (r.risk) throw new Error('触发客户端风控(请稍后在客户端完成验证后重试)');
  328. if (r.authFail) throw new Error('登录态失效(ksid 过期),请在商家客户端重新登录');
  329. if (r.code != null && r.code !== 0 && String(r.code) !== '0') {
  330. throw new Error('接口返回异常 code=' + r.code + (r.msg ? ' ' + r.msg : ''));
  331. }
  332. return (r.daily || []).map((d) => normalizeRow(d, ctx));
  333. }
  334. function requestSyncCancel() {
  335. if (syncState.running) syncState.cancelRequested = true;
  336. return syncSnapshot();
  337. }
  338. // ---------- 查询 / 汇总 ----------
  339. const SUM_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt', 'actOrdCnt', 'actSubsidyAmt'];
  340. const RATE_FIELDS = ['clkExpRate', 'ordClkRate'];
  341. function summarizeRows(rows) {
  342. const s = { days: new Set(), shops: new Set() };
  343. for (const f of SUM_FIELDS) s[f] = 0;
  344. let rateWeight = { clkExpRate: 0, ordClkRate: 0 };
  345. let rateBase = { clkExpRate: 0, ordClkRate: 0 };
  346. for (const r of rows) {
  347. s.days.add(r.date);
  348. if (r.shopId) s.shops.add(r.shopId);
  349. for (const f of SUM_FIELDS) s[f] += Number(r[f]) || 0;
  350. if (Number(r.expUv) > 0) { rateWeight.clkExpRate += (Number(r.clkExpRate) || 0) * Number(r.expUv); rateBase.clkExpRate += Number(r.expUv); }
  351. if (Number(r.orderUserCnt) > 0) { rateWeight.ordClkRate += (Number(r.ordClkRate) || 0) * Number(r.orderUserCnt); rateBase.ordClkRate += Number(r.orderUserCnt); }
  352. }
  353. s.perActualPay = s.validOrderCnt > 0 ? +(s.grossAmt / s.validOrderCnt).toFixed(2) : 0;
  354. for (const f of SUM_FIELDS) s[f] = Math.round(s[f] * 100) / 100;
  355. for (const f of RATE_FIELDS) s[f] = rateBase[f] > 0 ? +(rateWeight[f] / rateBase[f]).toFixed(4) : 0;
  356. s.dayCount = s.days.size;
  357. s.shopCount = s.shops.size;
  358. delete s.days;
  359. delete s.shops;
  360. return s;
  361. }
  362. function queryReport({ entity, from, to, shopId, shops, scope, q } = {}) {
  363. const key = store.sanitizeKey(entity);
  364. const meta = store.getEntity(key);
  365. let rows = store.queryRows(key, { from, to, scope });
  366. if (shopId) rows = rows.filter((r) => String(r.shopId) === String(shopId));
  367. if (Array.isArray(shops) && shops.length) {
  368. const wanted = new Set(shops.map(String));
  369. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  370. }
  371. if (q) {
  372. const needle = String(q).toLowerCase();
  373. rows = rows.filter((r) => String(r.shopName || '').toLowerCase().includes(needle));
  374. }
  375. return {
  376. entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
  377. from: from || null, to: to || null,
  378. columns: REPORT_COLUMNS,
  379. summary: summarizeRows(rows),
  380. rows,
  381. };
  382. }
  383. /** BI 聚合视图:group = 'shop'(按门店)| 'date'(按日期) */
  384. function aggregateReport({ entity, from, to, group = 'shop', shops } = {}) {
  385. const key = store.sanitizeKey(entity);
  386. let rows = store.queryRows(key, { from, to, scope: 'shop' });
  387. if (Array.isArray(shops) && shops.length) {
  388. const wanted = new Set(shops.map(String));
  389. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  390. }
  391. const buckets = new Map();
  392. for (const r of rows) {
  393. const k = group === 'date' ? r.date : String(r.shopId || '');
  394. if (!buckets.has(k)) buckets.set(k, []);
  395. buckets.get(k).push(r);
  396. }
  397. const out = [];
  398. for (const [k, list] of buckets) {
  399. const first = list[0] || {};
  400. const base = group === 'date'
  401. ? { date: k, shopName: `${list.length} 家门店`, shopId: '', scope: 'agg-date' }
  402. : { date: '', shopName: first.shopName || k, shopId: k, scope: 'agg-shop' };
  403. const s = summarizeRows(list);
  404. out.push({ ...base, ...s });
  405. }
  406. out.sort((a, b) => (group === 'date' ? (a.date < b.date ? -1 : 1) : (b.grossAmt || 0) - (a.grossAmt || 0)));
  407. return { columns: REPORT_COLUMNS, rows: out, summary: summarizeRows(rows) };
  408. }
  409. // ---------- CSV 导出 ----------
  410. function csvEscape(v) {
  411. if (v == null) return '';
  412. const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
  413. return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
  414. }
  415. function rowsToCsv(columns, rows) {
  416. const head = columns.map((c) => c.label);
  417. const lines = [head.join(',')];
  418. for (const r of rows) {
  419. const cells = columns.map((c) => {
  420. let v = r[c.key];
  421. if (v == null) v = '';
  422. if (c.type === 'pct') v = v === '' ? '' : (Number(v) * 100).toFixed(2) + '%';
  423. if (c.type === 'date' && /^\d{8}$/.test(String(v))) {
  424. const s = String(v);
  425. v = `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
  426. }
  427. return csvEscape(v);
  428. });
  429. lines.push(cells.join(','));
  430. }
  431. return '\ufeff' + lines.join('\r\n');
  432. }
  433. module.exports = {
  434. DEFAULT_CDP_PORT, REPORT_COLUMNS,
  435. naposStatus, fetchTree,
  436. runSync, syncSnapshot, requestSyncCancel,
  437. queryReport, aggregateReport, summarizeRows,
  438. rowsToCsv,
  439. store,
  440. };