reports-store.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. 'use strict';
  2. /**
  3. * 数据报表 · 本地数据湖
  4. * ----------------------------------------------------------------------------
  5. * 把从淘宝闪购商家后台取回的经营数据沉淀到本地 JSON 文件,支持:
  6. * · 多主体(每登录一个商家账号即一个主体 entity)
  7. * · 主体内分:全公司 / 连锁 / 单店 三种粒度的行
  8. * · 按「日期 × 门店」upsert 合并,可反复同步同一区间不产生重复
  9. *
  10. * 文件布局(项目根 data/reports/):
  11. * entities.json 主体注册表(含快照的连锁/门店结构)
  12. * <entityKey>/daily-YYYYMM.json 按月分片的数据行(行键 date|scope|shopId)
  13. * <entityKey>/daily.json 旧版单文件;读到的首次写入时自动迁移为分片并删除
  14. * <entityKey>/syncs.json 同步历史
  15. *
  16. * 按月分片的意义:同步/查询只触碰命中月份的分片,重写体积有上界;
  17. * 过期修剪等价于删除旧月份文件。
  18. */
  19. const fs = require('fs');
  20. const path = require('path');
  21. const LAKE_DIR = path.join(__dirname, 'data', 'reports');
  22. // ---------- 通用 ----------
  23. function ensureDir(p) {
  24. fs.mkdirSync(p, { recursive: true });
  25. return p;
  26. }
  27. function readJsonSafe(file, fallback) {
  28. try {
  29. return JSON.parse(fs.readFileSync(file, 'utf8'));
  30. } catch {
  31. return fallback;
  32. }
  33. }
  34. function writeJsonAtomic(file, obj) {
  35. ensureDir(path.dirname(file));
  36. const temp = file + '.tmp';
  37. fs.writeFileSync(temp, JSON.stringify(obj), 'utf8');
  38. fs.renameSync(temp, file);
  39. }
  40. function sanitizeKey(v) {
  41. return String(v || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
  42. }
  43. // ---------- 主体注册表 ----------
  44. function registryFile() {
  45. return path.join(LAKE_DIR, 'entities.json');
  46. }
  47. function loadRegistry() {
  48. return readJsonSafe(registryFile(), { entities: [] });
  49. }
  50. function saveRegistry(reg) {
  51. writeJsonAtomic(registryFile(), reg);
  52. }
  53. /**
  54. * 注册 / 更新一个主体(来自 shopTree 快照)。
  55. * 返回 { entity, created }。
  56. */
  57. function upsertEntity({ merchantId, merchantName, chains = [], shops = [] }) {
  58. const reg = loadRegistry();
  59. const key = sanitizeKey(merchantId);
  60. const now = new Date().toISOString();
  61. let entity = reg.entities.find((e) => e.key === key);
  62. let created = false;
  63. if (!entity) {
  64. created = true;
  65. entity = { key, firstSeen: now, lastSeen: now };
  66. reg.entities.push(entity);
  67. }
  68. entity.lastSeen = now;
  69. entity.merchantId = merchantId || null;
  70. entity.name = merchantName || entity.name || key;
  71. entity.chainCount = chains.length;
  72. entity.shopCount = shops.length;
  73. entity.chains = chains;
  74. entity.shops = shops;
  75. saveRegistry(reg);
  76. return { entity, created };
  77. }
  78. function listEntities() {
  79. const reg = loadRegistry();
  80. return reg.entities
  81. .slice()
  82. .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
  83. }
  84. function getEntity(key) {
  85. return loadRegistry().entities.find((e) => e.key === sanitizeKey(key)) || null;
  86. }
  87. // ---------- 数据行(按月分片) ----------
  88. const SHARD_RE = /^daily-(\d{6}|misc)\.json$/;
  89. function entityDir(entityKey) {
  90. return path.join(LAKE_DIR, sanitizeKey(entityKey));
  91. }
  92. function legacyFile(entityKey) {
  93. return path.join(entityDir(entityKey), 'daily.json');
  94. }
  95. function shardFile(entityKey, month) {
  96. return path.join(entityDir(entityKey), `daily-${month}.json`);
  97. }
  98. /** 行的月份桶:date 取 YYYYMMDD 前六位;异常格式进 misc 桶兜底 */
  99. function monthOf(row) {
  100. const m = /^(\d{6})/.exec(String(row.date || ''));
  101. return m ? m[1] : 'misc';
  102. }
  103. function sortRows(a, b) {
  104. return a.date === b.date
  105. ? String(a.shopId).localeCompare(String(b.shopId))
  106. : (a.date < b.date ? -1 : 1);
  107. }
  108. function readShardRows(file, into) {
  109. const data = readJsonSafe(file, null);
  110. if (data && Array.isArray(data.rows)) {
  111. for (const r of data.rows) if (r && r.date) into.set(rowKey(r), r);
  112. }
  113. }
  114. /**
  115. * 全量视图:旧单文件 + 各月分片合并(分片优先于旧文件,覆盖迁移中断窗口)。
  116. * 对上层仍是「一个 rows 数组」,与分片前语义一致。
  117. */
  118. function loadRows(entityKey) {
  119. const byKey = new Map();
  120. readShardRows(legacyFile(entityKey), byKey);
  121. let shards = [];
  122. try {
  123. shards = fs.readdirSync(entityDir(entityKey)).filter((f) => SHARD_RE.test(f)).sort();
  124. } catch { /* 目录不存在 */ }
  125. for (const f of shards) readShardRows(path.join(entityDir(entityKey), f), byKey);
  126. const rows = [...byKey.values()];
  127. rows.sort(sortRows);
  128. return rows;
  129. }
  130. function writeShard(entityKey, month, rows) {
  131. writeJsonAtomic(shardFile(entityKey, month), {
  132. version: 2, updated: new Date().toISOString(), rows: rows.slice().sort(sortRows),
  133. });
  134. }
  135. function removeLegacyFile(entityKey) {
  136. try { fs.unlinkSync(legacyFile(entityKey)); } catch { /* 已不存在 */ }
  137. }
  138. /** 整体替换某主体全部行(语义与旧 saveRows 一致),会清掉不再存在的月份分片 */
  139. function saveRows(entityKey, rows) {
  140. const byMonth = new Map();
  141. for (const r of rows) {
  142. if (!r || !r.date) continue;
  143. const m = monthOf(r);
  144. if (!byMonth.has(m)) byMonth.set(m, []);
  145. byMonth.get(m).push(r);
  146. }
  147. const dir = entityDir(entityKey);
  148. try {
  149. for (const f of fs.readdirSync(dir)) {
  150. const m = SHARD_RE.exec(f);
  151. if (m && !byMonth.has(m[1])) fs.unlinkSync(path.join(dir, f));
  152. }
  153. } catch { /* 目录不存在则写时自建 */ }
  154. for (const [m, list] of byMonth) writeShard(entityKey, m, list);
  155. removeLegacyFile(entityKey);
  156. }
  157. function rowKey(row) {
  158. // scope 与 chainId 都必须参与键:全公司行、连锁行、区间汇总行的 shopId 都为空串,
  159. // 缺 chainId 时同日多个连锁/汇总行会互相覆盖(只留最后写入的一条)
  160. return `${row.date}|${row.scope || ''}|${row.chainId == null ? '' : String(row.chainId)}|${row.shopId == null ? '' : String(row.shopId)}`;
  161. }
  162. /** 合并写入:按 date|scope|chainId|shopId upsert,浅字段覆盖(raw 整体替换)。
  163. * 只重写被本次数据触及的月份分片;旧单文件在首次写入时整体迁移。 */
  164. function upsertRows(entityKey, incoming) {
  165. if (!Array.isArray(incoming) || !incoming.length) return { added: 0, updated: 0, total: 0 };
  166. const rows = loadRows(entityKey);
  167. const index = new Map(rows.map((r, i) => [rowKey(r), i]));
  168. let added = 0;
  169. let updated = 0;
  170. const touched = new Set();
  171. for (const next of incoming) {
  172. if (!next || !next.date) continue;
  173. touched.add(monthOf(next));
  174. const k = rowKey(next);
  175. const prev = index.has(k) ? rows[index.get(k)] : null;
  176. if (prev) {
  177. Object.assign(prev, next, { raw: next.raw || prev.raw || null });
  178. updated += 1;
  179. } else {
  180. rows.push(next);
  181. index.set(k, rows.length - 1);
  182. added += 1;
  183. }
  184. }
  185. const hasLegacy = fs.existsSync(legacyFile(entityKey));
  186. const byMonth = new Map();
  187. for (const r of rows) {
  188. const m = monthOf(r);
  189. // 未触及且无旧文件要迁移的月份保留原分片,不重写
  190. if (!hasLegacy && !touched.has(m)) continue;
  191. if (!byMonth.has(m)) byMonth.set(m, []);
  192. byMonth.get(m).push(r);
  193. }
  194. for (const [m, list] of byMonth) writeShard(entityKey, m, list);
  195. if (hasLegacy) removeLegacyFile(entityKey);
  196. return { added, updated, total: rows.length };
  197. }
  198. /** 行过滤:主体 + 区间 + 门店 + 粒度 */
  199. function queryRows(entityKey, { from, to, shopId, scope } = {}) {
  200. return loadRows(entityKey).filter((r) => {
  201. if (from && r.date < from) return false;
  202. if (to && r.date > to) return false;
  203. if (shopId && String(r.shopId) !== String(shopId)) return false;
  204. if (scope && r.scope !== scope) return false;
  205. return true;
  206. });
  207. }
  208. function datesInLake(entityKey) {
  209. const set = new Set();
  210. for (const r of loadRows(entityKey)) set.add(r.date);
  211. return [...set].sort();
  212. }
  213. function deleteEntityData(entityKey) {
  214. const dir = path.join(LAKE_DIR, sanitizeKey(entityKey));
  215. fs.rmSync(dir, { recursive: true, force: true });
  216. }
  217. // ---------- 同步历史 ----------
  218. function syncsFile(entityKey) {
  219. return path.join(LAKE_DIR, sanitizeKey(entityKey), 'syncs.json');
  220. }
  221. function appendSyncLog(entityKey, entry) {
  222. const file = syncsFile(entityKey);
  223. const data = readJsonSafe(file, { runs: [] });
  224. data.runs.unshift(entry);
  225. if (data.runs.length > 50) data.runs.length = 50;
  226. writeJsonAtomic(file, data);
  227. }
  228. function loadSyncLogs(entityKey) {
  229. return readJsonSafe(syncsFile(entityKey), { runs: [] }).runs;
  230. }
  231. module.exports = {
  232. LAKE_DIR,
  233. loadRegistry, saveRegistry, upsertEntity, listEntities, getEntity,
  234. loadRows, saveRows, upsertRows, queryRows, datesInLake, deleteEntityData, rowKey,
  235. appendSyncLog, loadSyncLogs,
  236. sanitizeKey,
  237. };