diff --git a/backend/scripts/gen-daily-inventory-baseline.cjs b/backend/scripts/gen-daily-inventory-baseline.cjs
new file mode 100644
index 0000000..8ed220f
--- /dev/null
+++ b/backend/scripts/gen-daily-inventory-baseline.cjs
@@ -0,0 +1,106 @@
+process.env.CANGKU_SKIP_BOOTSTRAP = '1';
+const fs = require('fs');
+const path = require('path');
+const XLSX = require('xlsx');
+const cloudDb = require('../src/config/cloudDatabase');
+
+// 表格的 12 个流水列 → 配置字段名
+const FLOW_FIELDS = [
+ ['加工进仓', 'processIn'],
+ ['采购退货', 'purchaseReturn'],
+ ['采购进仓', 'purchaseIn'],
+ ['其它进仓', 'otherIn'],
+ ['其它退货', 'otherReturn'],
+ ['销售出仓', 'salesOut'],
+ ['盘点', 'stocktaking'],
+ ['加工出仓', 'processOut'],
+ ['调拨出', 'transferOut'],
+ ['销售退货', 'salesReturn'],
+ ['调拨入', 'transferIn'],
+ ['其它出库', 'otherOut'],
+];
+
+(async () => {
+ const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx');
+ const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' });
+ const h = rows[0];
+ const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i]));
+
+ const level1 = String(rows[1][idx['一级功能按键']]).trim();
+ const level2 = String(rows[1][idx['二级功能按键']]).trim();
+ console.log('一级功能:', level1, '| 二级功能:', level2);
+
+ const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`);
+ const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id]));
+
+ const records = [];
+ const missing = [];
+ for (const r of rows.slice(1)) {
+ const sheetName = String(r[idx['仓库']] || '').trim();
+ if (!sheetName) continue;
+ const base = sheetName.replace(/主仓$/, '');
+ const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null;
+ if (wmsCoId == null) missing.push(sheetName);
+
+ const num = (k) => {
+ const v = r[idx[k]];
+ return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0;
+ };
+
+ const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)]));
+ const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理
+ records.push({
+ warehouse: sheetName,
+ dbWarehouse: base,
+ wmsCoId,
+ openingQty: num('期初数量'),
+ openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0,
+ ...flows,
+ });
+ }
+
+ console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)');
+
+ const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en }));
+ const out = `/**
+ * 每日进销存 —— 2026-09-22 进销存基准流水
+ *
+ * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
+ * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
+ * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
+ *
+ * 口径约定:
+ * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
+ * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
+ * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
+ */
+
+/** 一级/二级菜单名(取自表格) */
+const DAILY_INVENTORY_LEVEL1 = '${level1}';
+const DAILY_INVENTORY_LEVEL2 = '${level2}';
+
+/** 基准数据日期 */
+const BASELINE_DATE = '2026-09-22';
+
+/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */
+const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)};
+
+/** 25 个仓库的 22 号基准流水 */
+const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)};
+
+module.exports = {
+ DAILY_INVENTORY_LEVEL1,
+ DAILY_INVENTORY_LEVEL2,
+ BASELINE_DATE,
+ FLOW_COLUMNS,
+ DAILY_INVENTORY_BASELINE,
+};
+`;
+
+ const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js');
+ fs.writeFileSync(target, out, 'utf8');
+ console.log('已生成:', target);
+ console.log('\n前 2 条预览:');
+ console.log(JSON.stringify(records.slice(0, 2), null, 1));
+ process.exit(0);
+})().catch((e) => { console.log('ERR', e.message); process.exit(1); });
diff --git a/backend/scripts/verify-daily-inventory.js b/backend/scripts/verify-daily-inventory.js
new file mode 100644
index 0000000..38c1ec1
--- /dev/null
+++ b/backend/scripts/verify-daily-inventory.js
@@ -0,0 +1,96 @@
+process.env.CANGKU_SKIP_BOOTSTRAP = '1';
+const http = require('http');
+const XLSX = require('xlsx');
+
+const FIELD_MAP = [
+ ['期初数量', 'openingQty'],
+ ['加工进仓', 'processIn'],
+ ['采购退货', 'purchaseReturn'],
+ ['采购进仓', 'purchaseIn'],
+ ['其它进仓', 'otherIn'],
+ ['其它退货', 'otherReturn'],
+ ['销售出仓', 'salesOut'],
+ ['盘点', 'stocktaking'],
+ ['加工出仓', 'processOut'],
+ ['调拨出', 'transferOut'],
+ ['销售退货', 'salesReturn'],
+ ['调拨入', 'transferIn'],
+ ['期初', 'openingQtyExtra'],
+ ['其它出库', 'otherOut'],
+ ['期末数量', 'closingQty'],
+];
+
+function req(method, path, token, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const r = http.request({
+ host: '127.0.0.1', port: 4001, path, method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
+ ...(token ? { Authorization: 'Bearer ' + token } : {}),
+ },
+ }, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); });
+ r.on('error', reject);
+ if (data) r.write(data);
+ r.end();
+ });
+}
+
+(async () => {
+ const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body);
+ const token = login.access_token || login.token;
+ const res = await req('GET', '/api/daily-inventory', token);
+ if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); }
+ const d = JSON.parse(res.body).data;
+
+ const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx');
+ const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' });
+ const h = rows[0];
+ const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i]));
+ const byName = new Map(d.items.map((it) => [it.warehouse, it]));
+
+ console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length);
+ console.log();
+
+ const diffCounts = {};
+ let totalCells = 0;
+ let diffCells = 0;
+ const samples = [];
+
+ for (const r of rows.slice(1)) {
+ const name = String(r[idx['仓库']] || '').trim();
+ if (!name) continue;
+ const item = byName.get(name);
+ if (!item) { console.log('接口缺少仓库:', name); continue; }
+ for (const [cn, field] of FIELD_MAP) {
+ const raw = r[idx[cn]];
+ const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0;
+ const actual = Number(item[field] ?? 0);
+ totalCells += 1;
+ // 期末允许 1 件内的副本同步误差
+ const tol = field === 'closingQty' ? 10 : 0;
+ if (Math.abs(expected - actual) > tol) {
+ diffCells += 1;
+ diffCounts[cn] = (diffCounts[cn] || 0) + 1;
+ if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`);
+ }
+ }
+ }
+
+ console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells);
+ if (diffCells) {
+ console.log('\n不一致列统计:', JSON.stringify(diffCounts));
+ console.log('样例:');
+ samples.forEach((s) => console.log(' ' + s));
+ } else {
+ console.log('✓ 全部单元格与表格一致');
+ }
+
+ const withGrade = d.items.filter((x) => x.hasGrade).length;
+ console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length);
+ console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、'));
+ console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty);
+ console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' '));
+ process.exit(0);
+})().catch((e) => { console.log('ERR', e.message); process.exit(1); });
diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js
new file mode 100644
index 0000000..7bdc9a1
--- /dev/null
+++ b/backend/src/api/dailyInventory.js
@@ -0,0 +1,179 @@
+const express = require('express');
+const cloudDb = require('../config/cloudDatabase');
+const {
+ DAILY_INVENTORY_BASELINE,
+ BASELINE_DATE,
+ FLOW_COLUMNS,
+} = require('../config/dailyInventoryBaseline');
+
+const router = express.Router();
+
+/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
+const AS_OF_TIME = '01:00';
+
+const toNumber = (v) => {
+ if (v == null) return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+};
+
+/** 快照日 + 次日 01:00 作为统一的库龄/分级基准 */
+function nextDayAsOf(bizDate) {
+ if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
+ const d = new Date(`${bizDate}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() + 1);
+ return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
+}
+
+async function fetchLatestBizDate() {
+ const rows = await cloudDb.query(
+ `SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
+ );
+ return rows[0]?.d || null;
+}
+
+/**
+ * 派生列(SQL CASE WHEN 下沉计算)
+ * - 库龄(天) = DATEDIFF(asOf, created)
+ * - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%
+ * 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock
+ * - 四级分级:库龄 + 周转率双维度
+ */
+function buildDerivedColumns(asOf) {
+ const A = '@asOf';
+ void A;
+ const ageDays = `DATEDIFF(DAY, m.created, @asOf)`;
+ const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`;
+ return {
+ ageDays,
+ turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`,
+ levelExpr: `
+ CASE
+ WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL
+ WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn'
+ WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow'
+ WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow'
+ WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale'
+ WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale'
+ WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie'
+ ELSE NULL
+ END`,
+ };
+}
+
+/** 该仓快照里的四级库存量与平均周转天数 */
+async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) {
+ const D = buildDerivedColumns(asOf);
+ const rows = await cloudDb.query(
+ `SELECT COUNT(*) AS skuCount,
+ SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock,
+ AVG(${D.turnoverDays}) AS avgTurnover,
+ SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel,
+ SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel,
+ SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel,
+ SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel
+ FROM rpt.pm_stockMonitor m
+ WHERE m.warehouse = @warehouse
+ AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`,
+ { warehouse: dbWarehouse, bizDate, asOf }
+ );
+ const r = rows[0] || {};
+ if (!Number(r.skuCount || 0)) return null;
+ return {
+ skuCount: toNumber(r.skuCount),
+ gradeStock: toNumber(r.gradeStock),
+ turnoverDays: toNumber(r.avgTurnover),
+ warnLevel: toNumber(r.warnLevel),
+ slowLevel: toNumber(r.slowLevel),
+ staleLevel: toNumber(r.staleLevel),
+ zombieLevel: toNumber(r.zombieLevel),
+ };
+}
+
+/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */
+async function fetchClosingQty(wmsCoId) {
+ if (wmsCoId == null) return null;
+ const rows = await cloudDb.query(
+ `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty,
+ COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount,
+ MAX(modified) AS takenAt
+ FROM rpt.jst_inventoryInfo
+ WHERE wms_co_id = @wmsCoId`,
+ { wmsCoId }
+ );
+ const r = rows[0] || {};
+ return {
+ closingQty: toNumber(r.qty),
+ closingSkuCount: toNumber(r.skuCount) || 0,
+ closingTakenAt: r.takenAt || null,
+ };
+}
+
+/**
+ * GET /api/daily-inventory
+ * 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量
+ *
+ * 数据源分工:
+ * - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js)
+ * - 期末数量:jst_inventoryInfo 实时库存
+ * - 库存周转天数 / 四级库存量:pm_stockMonitor 快照
+ */
+router.get('/', async (req, res) => {
+ try {
+ const latestBizDate = await fetchLatestBizDate().catch(() => null);
+ const asOf = nextDayAsOf(latestBizDate);
+
+ const items = [];
+ for (const base of DAILY_INVENTORY_BASELINE) {
+ const flows = {};
+ for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0;
+
+ const closing = await fetchClosingQty(base.wmsCoId).catch(() => null);
+ const grade = base.dbWarehouse && latestBizDate
+ ? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null)
+ : null;
+
+ items.push({
+ warehouse: base.warehouse,
+ dbWarehouse: base.dbWarehouse,
+ openingQty: base.openingQty,
+ openingQtyExtra: base.openingQtyExtra,
+ ...flows,
+ closingQty: closing?.closingQty ?? null,
+ closingSkuCount: closing?.closingSkuCount ?? null,
+ closingTakenAt: closing?.closingTakenAt ?? null,
+ turnoverDays: grade?.turnoverDays ?? null,
+ gradeStock: grade?.gradeStock ?? null,
+ warnLevel: grade?.warnLevel ?? null,
+ slowLevel: grade?.slowLevel ?? null,
+ staleLevel: grade?.staleLevel ?? null,
+ zombieLevel: grade?.zombieLevel ?? null,
+ hasGrade: Boolean(grade),
+ });
+ }
+
+ // 合计行:便于与表格总额核对
+ const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0);
+ const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true };
+ for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) {
+ totals[k] = Number(sum(k).toFixed(2));
+ }
+
+ res.json({
+ success: true,
+ data: {
+ baselineDate: BASELINE_DATE,
+ latestBizDate,
+ asOf,
+ flowColumns: FLOW_COLUMNS,
+ items,
+ totals,
+ },
+ });
+ } catch (error) {
+ console.error('[daily-inventory] 查询失败:', error.message);
+ res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
+ }
+});
+
+module.exports = router;
diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js
new file mode 100644
index 0000000..f8a9c5f
--- /dev/null
+++ b/backend/src/config/dailyInventoryBaseline.js
@@ -0,0 +1,558 @@
+/**
+ * 每日进销存 —— 2026-09-22 进销存基准流水
+ *
+ * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出)
+ * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库
+ * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。
+ *
+ * 口径约定:
+ * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储)
+ * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确)
+ * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算
+ */
+
+/** 一级/二级菜单名(取自表格) */
+const DAILY_INVENTORY_LEVEL1 = '大货库存监控';
+const DAILY_INVENTORY_LEVEL2 = '每日进销存';
+
+/** 基准数据日期 */
+const BASELINE_DATE = '2026-09-22';
+
+/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */
+const FLOW_COLUMNS = [
+ {
+ "label": "加工进仓",
+ "field": "processIn"
+ },
+ {
+ "label": "采购退货",
+ "field": "purchaseReturn"
+ },
+ {
+ "label": "采购进仓",
+ "field": "purchaseIn"
+ },
+ {
+ "label": "其它进仓",
+ "field": "otherIn"
+ },
+ {
+ "label": "其它退货",
+ "field": "otherReturn"
+ },
+ {
+ "label": "销售出仓",
+ "field": "salesOut"
+ },
+ {
+ "label": "盘点",
+ "field": "stocktaking"
+ },
+ {
+ "label": "加工出仓",
+ "field": "processOut"
+ },
+ {
+ "label": "调拨出",
+ "field": "transferOut"
+ },
+ {
+ "label": "销售退货",
+ "field": "salesReturn"
+ },
+ {
+ "label": "调拨入",
+ "field": "transferIn"
+ },
+ {
+ "label": "其它出库",
+ "field": "otherOut"
+ }
+];
+
+/** 25 个仓库的 22 号基准流水 */
+const DAILY_INVENTORY_BASELINE = [
+ {
+ "warehouse": "简尚-崔桥工厂仓主仓",
+ "dbWarehouse": "简尚-崔桥工厂仓",
+ "wmsCoId": 15038426,
+ "openingQty": 92388,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -3924,
+ "stocktaking": -1,
+ "processOut": 0,
+ "transferOut": -1461,
+ "salesReturn": 0,
+ "transferIn": 5437,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-山东青岗集仓主仓",
+ "dbWarehouse": "简尚-山东青岗集仓",
+ "wmsCoId": 15137523,
+ "openingQty": 88377,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 886,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -1058,
+ "stocktaking": -1,
+ "processOut": 0,
+ "transferOut": -11,
+ "salesReturn": 0,
+ "transferIn": 23,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-河南洛宁仓主仓",
+ "dbWarehouse": "简尚-河南洛宁仓",
+ "wmsCoId": 15539084,
+ "openingQty": 83305,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -314,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 15,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-新苏北仓主仓",
+ "dbWarehouse": "简尚-新苏北仓",
+ "wmsCoId": 15038347,
+ "openingQty": 77145,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -1744,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 649,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-横林二仓主仓",
+ "dbWarehouse": "简尚-横林二仓",
+ "wmsCoId": 15033302,
+ "openingQty": 63780,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -982,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": -17,
+ "salesReturn": 0,
+ "transferIn": 728,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-山东品牌仓主仓",
+ "dbWarehouse": "简尚-山东品牌仓",
+ "wmsCoId": 15182705,
+ "openingQty": 41025,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 150,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -157,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": -5,
+ "salesReturn": 0,
+ "transferIn": 4,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-睢宁仓主仓",
+ "dbWarehouse": "简尚-睢宁仓",
+ "wmsCoId": 15032603,
+ "openingQty": 25761,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -922,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 333,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-分销部专用仓主仓",
+ "dbWarehouse": "简尚-分销部专用仓",
+ "wmsCoId": 15045426,
+ "openingQty": 6380,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -264,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 200,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-新外贸仓主仓",
+ "dbWarehouse": "简尚-新外贸仓",
+ "wmsCoId": 15045308,
+ "openingQty": 5165,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "京仓-横林二仓京东自营仓主仓",
+ "dbWarehouse": "京仓-横林二仓京东自营仓",
+ "wmsCoId": 15835893,
+ "openingQty": 4260,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-分销部睢宁专用仓主仓",
+ "dbWarehouse": "简尚-分销部睢宁专用仓",
+ "wmsCoId": 15051960,
+ "openingQty": 3711,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-崔桥工厂品牌仓主仓",
+ "dbWarehouse": "简尚-崔桥工厂品牌仓",
+ "wmsCoId": 15870495,
+ "openingQty": 1831,
+ "openingQtyExtra": 0,
+ "processIn": 94,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -53,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-分销部苏北专用仓主仓",
+ "dbWarehouse": "简尚-分销部苏北专用仓",
+ "wmsCoId": 15045325,
+ "openingQty": 1440,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营C横林二仓专用仓主仓",
+ "dbWarehouse": "简尚-运营C横林二仓专用仓",
+ "wmsCoId": 15301814,
+ "openingQty": 2141,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -992,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-济南外贸仓主仓",
+ "dbWarehouse": "简尚-济南外贸仓",
+ "wmsCoId": 15051888,
+ "openingQty": 712,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-分销部崔桥专用仓主仓",
+ "dbWarehouse": "简尚-分销部崔桥专用仓",
+ "wmsCoId": 15404402,
+ "openingQty": 620,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营C崔桥工厂仓专用仓主仓",
+ "dbWarehouse": "简尚-运营C崔桥工厂仓专用仓",
+ "wmsCoId": 15301815,
+ "openingQty": 286,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营D苏北专用仓主仓",
+ "dbWarehouse": "简尚-运营D苏北专用仓",
+ "wmsCoId": 15406876,
+ "openingQty": 280,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营C睢宁专用仓主仓",
+ "dbWarehouse": "简尚-运营C睢宁专用仓",
+ "wmsCoId": 15301813,
+ "openingQty": 193,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营C新苏北专用仓主仓",
+ "dbWarehouse": "简尚-运营C新苏北专用仓",
+ "wmsCoId": 15301812,
+ "openingQty": 155,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营D睢宁专用仓主仓",
+ "dbWarehouse": "简尚-运营D睢宁专用仓",
+ "wmsCoId": 15051907,
+ "openingQty": 109,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -1,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-山东青岗集京东仓主仓",
+ "dbWarehouse": "简尚-山东青岗集京东仓",
+ "wmsCoId": 15406664,
+ "openingQty": 90,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-运营D专用仓库主仓",
+ "dbWarehouse": "简尚-运营D专用仓库",
+ "wmsCoId": 15051915,
+ "openingQty": 180,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -125,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "简尚-崔桥工厂京东仓主仓",
+ "dbWarehouse": "简尚-崔桥工厂京东仓",
+ "wmsCoId": 15045345,
+ "openingQty": 1,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": 0,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ },
+ {
+ "warehouse": "京仓-横林二仓京东云仓主仓",
+ "dbWarehouse": "京仓-横林二仓京东云仓",
+ "wmsCoId": 15197138,
+ "openingQty": -3082,
+ "openingQtyExtra": 0,
+ "processIn": 0,
+ "purchaseReturn": 0,
+ "purchaseIn": 0,
+ "otherIn": 0,
+ "otherReturn": 0,
+ "salesOut": -48,
+ "stocktaking": 0,
+ "processOut": 0,
+ "transferOut": 0,
+ "salesReturn": 0,
+ "transferIn": 0,
+ "otherOut": 0
+ }
+];
+
+module.exports = {
+ DAILY_INVENTORY_LEVEL1,
+ DAILY_INVENTORY_LEVEL2,
+ BASELINE_DATE,
+ FLOW_COLUMNS,
+ DAILY_INVENTORY_BASELINE,
+};
diff --git a/backend/src/index.js b/backend/src/index.js
index 88daa64..f9802ee 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -1,890 +1,892 @@
-// 后端服务入口
-const path = require('path');
-
-require('dotenv').config();
-require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
-
-const express = require('express');
-const cors = require('cors');
-const { CubejsServer } = require('@cubejs-backend/server');
-const mysql = require('mysql2/promise');
-
-const installRoutes = require('./api/install');
-const authMiddleware = require('./middleware/auth');
-const { createPageDataCache } = require('./middleware/pageDataCache');
-const adminRoutes = require('./api/admin');
-const planningUploadRoutes = require('./api/planningUpload');
-const planningMayProductPublicRoutes = require('./api/planningMayProductPublic');
-const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal');
-const presaleExternalRoutes = require('./api/presaleExternal');
-const delistExternalRoutes = require('./api/delistExternal');
-const campaignCloudRoutes = require('./api/campaignCloud');
-const desktopClientRoutes = require('./api/desktopClient');
-const desktopPresenceRoutes = require('./api/desktopPresence');
-const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks');
-const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal');
-const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus');
-const thirdPartySsoRoutes = require('./api/thirdPartySso');
-const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
-const channelRoutes = require('./api/channel');
-const analysisRoutes = require('./api/analysis');
-const productCodeBoardRoutes = require('./api/productCodeBoard');
-const productAnalysisRoutes = require('./api/productAnalysis');
-const marketingEventsRoutes = require('./api/marketingEvents');
-const promotionModuleRoutes = require('./api/promotionModule');
-const productMonitoringRoutes = require('./api/productMonitoring');
-const modularStockMonitorRoutes = require('./api/modularStockMonitor');
-const mkhBomRoutes = require('./api/mkhBom');
-const database = require('./config/database');
-const cloudDb = require('./config/cloudDatabase');
-const jianSunDb = require('./config/jianSunDatabase');
-const redis = require('./config/redis');
-const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh');
-const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob');
-const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob');
-const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
-const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
-const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
-const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
-const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
-const {
- startAlibaba1688ImageSearchExpiryScheduler,
-} = require('./jobs/alibaba1688ImageSearchExpiryJob');
-const {
- startMkhReplenishAlertScheduler,
-} = require('./jobs/mkhReplenishAlertJob');
-const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
-const { ensureLocalSchema } = require('./services/localSchema');
-const {
- prewarmChannelHotData,
- prewarmChannelIdDefaultSnapshots,
- prewarmChannelIdDashboardDefaultSnapshot,
- prewarmChannelStockMonitorDashboardDefaultSnapshot,
-} = require('./services/channelAnalysis');
-const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis');
-const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard');
-const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService');
-const {
- getPublicSystemConfigCache,
- getStalePublicSystemConfigCache,
- setPublicSystemConfigCache,
-} = require('./services/publicSystemConfigCache');
-const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility');
-const {
- shouldRequireImageSearchCredentials,
- verifyImageSearchCredentials,
-} = require('./services/imageSearchCredentialGuard');
-
-const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max(
- Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60,
- 60
-);
-const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max(
- Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000,
- 5 * 1000
-);
-const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max(
- Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300,
- 30
-) * 1000;
-const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max(
- Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800,
- 500
-);
-const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '')
- .trim()
- .toLowerCase() === 'true';
-
-function getBuildIdentity() {
- return {
- sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null,
- sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null,
- };
-}
-const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max(
- Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000,
- 10000
-);
-const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max(
- Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000,
- 10000
-);
-
-function shouldCachePlanningUploadResponse(body = {}) {
- if (!body || body.error || body.success === false) {
- return false;
- }
-
- const payload = body.data || body;
- if (payload?.pending || payload?.cache?.backgroundRefresh) {
- return false;
- }
-
- return true;
-}
-
-function shouldHandlePlanningUploadPageCache(req) {
- // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。
- // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。
- // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。
- // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。
- // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。
- if (
- req.path === '/'
- || req.path.startsWith('/table/may_product_data')
- || req.path.startsWith('/analysis/may-channel-insight')
- || req.path.startsWith('/analysis/ops-b-incentive')
- || req.path.startsWith('/selection-fission')
- ) {
- return false;
- }
-
- return true;
-}
-
-const analysisPageCache = createPageDataCache({
- namespace: 'analysis',
- redisTtlSeconds: 300,
-});
-const channelPageCache = createPageDataCache({
- namespace: 'channel',
- redisTtlSeconds: 300,
- // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。
- shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path)
- && !req.path.startsWith('/export-jobs'),
-});
-const planningUploadPageCache = createPageDataCache({
- namespace: 'planning-upload',
- version: 'v27-bypass-may-snapshot-pages-20260423',
- redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS,
- shouldHandleRequest: shouldHandlePlanningUploadPageCache,
- shouldCacheBody: shouldCachePlanningUploadResponse,
-});
-let serverMode = 'install';
-
-function isStartupPrewarmEnabled() {
- return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true';
-}
-
-function runGuardedPrewarm(label, handler) {
- return runHeavyDatabaseTask(`prewarm:${label}`, handler);
-}
-
-function withTimeout(promise, timeoutMs, fallback) {
- let timer = null;
- return Promise.race([
- promise.finally(() => {
- if (timer) {
- clearTimeout(timer);
- }
- }),
- new Promise((resolve) => {
- timer = setTimeout(() => resolve(fallback), timeoutMs);
- if (typeof timer.unref === 'function') {
- timer.unref();
- }
- }),
- ]);
-}
-
-function hasCompleteCloudEnvConfig() {
- return cloudDb.hasCompleteEnvConfig();
-}
-
-function getCloudEnvConfig() {
- const config = cloudDb.getEnvBusinessDatabaseConfig();
- return {
- driver: config.driver || 'mssql',
- host: config.host,
- port: config.port || 1433,
- database: config.database,
- user: config.user,
- password: config.password,
- ssl: Boolean(config.ssl),
- };
-}
-
-function getBootstrapCloudConfig() {
- return {
- driver: 'mysql',
- host: process.env.LOCAL_DB_HOST,
- port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10),
- database: process.env.LOCAL_DB_NAME,
- user: process.env.LOCAL_DB_USER,
- password: process.env.LOCAL_DB_PASSWORD,
- ssl: false,
- bootstrap: true,
- };
-}
-
-// =====================
-// 从数据库获取云数据库配置
-// =====================
-async function getCloudDbConfig() {
- let localPool;
- try {
- // 先连接本地数据库读取配置
- localPool = mysql.createPool({
- host: process.env.LOCAL_DB_HOST,
- port: parseInt(process.env.LOCAL_DB_PORT || '3306'),
- user: process.env.LOCAL_DB_USER,
- password: process.env.LOCAL_DB_PASSWORD,
- database: process.env.LOCAL_DB_NAME,
- charset: 'utf8mb4',
- waitForConnections: true,
- connectionLimit: 2,
- maxIdle: 2,
- idleTimeout: 60000,
- queueLimit: 10,
- connectTimeout: 5000,
- enableKeepAlive: true,
- keepAliveInitialDelay: 0,
- });
-
- const [rows] = await localPool.query(
- 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1',
- ['active']
- );
-
- if (rows.length > 0) {
- const config = rows[0];
- if (config.use_env_config === 1 || config.use_env_config === true) {
- if (hasCompleteCloudEnvConfig()) {
- console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)');
- return getCloudEnvConfig();
- }
- console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置');
- }
-
- console.log('📌 使用数据库配置(来自 database_configs 表)');
- return {
- driver: config.driver || 'mysql',
- host: config.host,
- port: config.port || 3306,
- database: config.database_name,
- user: config.username,
- password: config.password,
- ssl: config.ssl === 1,
- };
- }
- } catch (error) {
- console.warn('⚠️ 无法从数据库读取配置:', error.message);
- } finally {
- if (localPool) {
- await localPool.end().catch((error) => {
- console.warn('⚠️ 关闭系统库临时连接池失败:', error.message);
- });
- }
- }
-
- // 回退到环境变量配置
- if (hasCompleteCloudEnvConfig()) {
- console.log('📌 使用环境变量配置(CLOUD_DB_*)');
- return getCloudEnvConfig();
- }
-
- console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式');
- return getBootstrapCloudConfig();
-}
-
-function applyCommonMiddleware(app) {
- app.set('trust proxy', 1);
- app.use(cors());
- app.use(express.json({
- limit: '15mb',
- verify: (req, _res, buffer) => {
- req.rawBody = buffer.toString('utf8');
- },
- }));
-}
-
-function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) {
- app.get('/api/admin/system-config', async (_req, res) => {
- const now = Date.now();
- const cachedConfig = getPublicSystemConfigCache(now);
- if (cachedConfig) {
- return res.json(cachedConfig);
- }
-
- try {
- const configs = await Promise.race([
- database.local.query('SELECT config_key, config_value, config_type FROM system_config'),
- new Promise((_, reject) => {
- setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS);
- }),
- ]);
- const result = {};
- filterPublicSystemConfigs(configs).forEach((config) => {
- if (config.config_type === 'boolean') {
- result[config.config_key] = config.config_value === 'true' || config.config_value === '1';
- } else if (config.config_type === 'number') {
- result[config.config_key] = Number(config.config_value);
- } else {
- result[config.config_key] = config.config_value;
- }
- });
- setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now);
- res.json(result);
- } catch (error) {
- const staleConfig = getStalePublicSystemConfigCache();
- if (staleConfig) {
- return res.json(staleConfig);
- }
-
- if (serverMode === 'install' || serverMode === 'database_unavailable') {
- return res.json({
- site_name: fallbackConfig.siteName,
- site_description: fallbackConfig.siteDescription,
- logo: '',
- });
- }
-
- console.warn('获取系统配置超时或失败,返回兜底配置:', error.message);
- return res.json({
- site_name: fallbackConfig.siteName,
- site_description: fallbackConfig.siteDescription,
- logo: '',
- });
- }
- });
-}
-
-function registerInstallModeRoutes(app, installStatus) {
- const unavailableHandler = (_req, res) => {
- res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' });
- };
-
- app.get('/health', async (_req, res) => {
- const status = await getInstallStatus();
- res.json({
- status: 'install_required',
- timestamp: new Date().toISOString(),
- build: getBuildIdentity(),
- install: status,
- });
- });
-
- app.use('/api/install', installRoutes);
- registerPublicSystemConfigRoute(app);
-
- app.get('/api/auth/providers', (_req, res) => {
- res.json({
- authBaseUrl: '',
- dingtalkEnabled: false,
- googleEnabled: false,
- installRequired: true,
- });
- });
- app.get('/api/auth/dingtalk/login', unavailableHandler);
- app.get('/api/auth/google/login', unavailableHandler);
- app.post('/api/auth/login', unavailableHandler);
- app.post('/api/auth/callback', unavailableHandler);
- app.get('/api/auth/verify', (_req, res) => {
- res.status(401).json({ valid: false, installRequired: true });
- });
-
- app.use('/api/admin', (_req, res) => {
- res.status(503).json({
- error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号',
- });
- });
-
- console.log('='.repeat(50));
- console.log('🛠️ 系统已进入首次安装模式');
- console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
- console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`);
- console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`);
- console.log('='.repeat(50));
-}
-
-async function startInstallServer(installStatus) {
- serverMode = 'install';
- const app = express();
- applyCommonMiddleware(app);
- registerInstallModeRoutes(app, installStatus);
-
- const port = parseInt(process.env.PORT || '4000', 10);
- await new Promise((resolve) => {
- app.listen(port, resolve);
- });
-}
-
-function registerDatabaseUnavailableRoutes(app, installStatus) {
- const unavailableHandler = (_req, res) => {
- res.status(503).json({
- error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务',
- databaseUnavailable: true,
- installRequired: false,
- });
- };
-
- app.get('/health', async (_req, res) => {
- const status = await getInstallStatus();
- res.status(503).json({
- status: 'database_unavailable',
- timestamp: new Date().toISOString(),
- build: getBuildIdentity(),
- install: status,
- });
- });
-
- app.use('/api/install', installRoutes);
- registerPublicSystemConfigRoute(app);
-
- app.get('/api/auth/providers', (_req, res) => {
- res.json({
- authBaseUrl: '',
- dingtalkEnabled: false,
- googleEnabled: false,
- installRequired: false,
- databaseUnavailable: true,
- });
- });
- app.get('/api/auth/verify', (_req, res) => {
- res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false });
- });
- app.post('/api/auth/login', unavailableHandler);
- app.post('/api/auth/callback', unavailableHandler);
- app.get('/api/auth/dingtalk/login', unavailableHandler);
- app.get('/api/auth/google/login', unavailableHandler);
- app.use('/api', unavailableHandler);
-
- console.log('='.repeat(50));
- console.log('🚧 系统数据库暂时无法连接,已进入降级模式');
- console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
- console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`);
- console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务');
- console.log('='.repeat(50));
-}
-
-async function startDatabaseUnavailableServer(installStatus) {
- serverMode = 'database_unavailable';
- const app = express();
- applyCommonMiddleware(app);
- registerDatabaseUnavailableRoutes(app, installStatus);
-
- const port = parseInt(process.env.PORT || '4000', 10);
- await new Promise((resolve) => {
- app.listen(port, resolve);
- });
-}
-
-async function createFullModeApp(cloudConfig) {
- const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql';
- console.log(`📊 数据库类型: ${dbType}`);
-
- const port = parseInt(process.env.PORT || '4000', 10);
-
- if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') {
- console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务');
- const app = express();
- await new Promise((resolve) => {
- app.listen(port, resolve);
- });
-
- return {
- app,
- cubeMode: 'disabled',
- cubeError: 'DISABLE_CUBEJS=true',
- };
- }
-
- try {
- const cubejsServer = new CubejsServer({
- schemaPath: 'model',
- dbType: dbType,
- logger: (msg, params) => {
- console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : '');
- },
- driverFactory: () => {
- // 根据 driver 类型选择驱动
- if (cloudConfig.driver === 'mssql') {
- const MSSQLDriver = require('@cubejs-backend/mssql-driver');
- return new MSSQLDriver({
- server: cloudConfig.host,
- port: cloudConfig.port,
- database: cloudConfig.database,
- user: cloudConfig.user,
- password: cloudConfig.password,
- options: {
- encrypt: cloudConfig.ssl,
- trustServerCertificate: true,
- },
- });
- }
-
- // 默认使用 MySQL
- const MySQLDriver = require('@cubejs-backend/mysql-driver');
- return new MySQLDriver({
- database: cloudConfig.database,
- host: cloudConfig.host,
- port: cloudConfig.port,
- user: cloudConfig.user,
- password: cloudConfig.password,
- ssl: cloudConfig.ssl,
- });
- },
- apiSecret: process.env.CUBEJS_API_SECRET,
- scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER,
- jwt: {
- key: process.env.CUBEJS_SECRET,
- algorithms: ['HS256'],
- },
- });
-
- const { app } = await cubejsServer.listen();
- return {
- app,
- cubeMode: 'cube',
- cubeError: null,
- };
- } catch (error) {
- console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message);
- const app = express();
- await new Promise((resolve) => {
- app.listen(port, resolve);
- });
-
- return {
- app,
- cubeMode: 'express-fallback',
- cubeError: error.message,
- };
- }
-}
-
-// =====================
-// 启动服务
-// =====================
-async function startServer(options = {}) {
- const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true';
- const { skipLocalSchema = envSkipLocalSchema } = options;
- serverMode = 'full';
- if (skipLocalSchema) {
- console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API');
- } else {
- await ensureLocalSchema();
- }
-
- if (shouldRequireImageSearchCredentials()) {
- const credentialStatus = await verifyImageSearchCredentials();
- console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`);
- }
-
- // 获取云数据库配置
- const cloudConfig = await getCloudDbConfig();
- if (cloudConfig.bootstrap) {
- console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`);
- } else {
- console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`);
- }
-
- // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。
- const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig);
-
- applyCommonMiddleware(cubejsApp);
-
- // =====================
- // 健康检查 (多数据源)
- // =====================
- cubejsApp.get('/health', async (_req, res) => {
- const [systemTest, businessTest, redisTest] = await Promise.all([
- withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }),
- withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }),
- withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }),
- ]);
-
- res.json({
- status: 'ok',
- timestamp: new Date().toISOString(),
- build: getBuildIdentity(),
- databases: {
- system: systemTest,
- local: systemTest,
- cloud: businessTest,
- },
- services: {
- redis: redisTest,
- cube: {
- success: cubeMode === 'cube',
- mode: cubeMode,
- message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`,
- },
- },
- });
- });
-
- // =====================
- // 认证 API
- // =====================
- cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders);
- cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin);
- cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin);
- cubejsApp.post('/api/auth/login', require('./api/auth').login);
- cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview);
- cubejsApp.post('/api/auth/callback', require('./api/auth').callback);
- cubejsApp.get('/api/auth/verify', require('./api/auth').verify);
-
- // =====================
- // 公开访问的 API(无需认证)
- // =====================
- cubejsApp.use('/api/install', installRoutes);
- cubejsApp.use('/api/sso', thirdPartySsoRoutes);
- cubejsApp.use('/api/desktop-client', desktopClientRoutes);
- cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes);
- cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes);
- cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes);
- cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes);
- cubejsApp.use('/api/campaign', campaignCloudRoutes);
- cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes);
- cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes);
- cubejsApp.use('/api/external/presale', presaleExternalRoutes);
-
- // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo)
- registerPublicSystemConfigRoute(cubejsApp);
-
- // =====================
- // 需要认证的 API
- // =====================
- cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes);
- cubejsApp.use('/api/admin', authMiddleware, adminRoutes);
- cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
- cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
- cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
- cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
- cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
- cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
- cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
- cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
- cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
- cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
- cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
-
- // =====================
- // 数据分析 API(公开访问,可根据需要添加认证)
- // =====================
- cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes);
-
- // =====================
- // 数据刷新 API(需要认证)
- // =====================
- cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => {
- try {
- const { date } = req.body;
- console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`);
- const result = await manualRefresh(date);
- res.json({ success: true, ...result });
- } catch (error) {
- res.status(500).json({ success: false, error: error.message });
- }
- });
-
- // =====================
- // 重新加载云数据库配置 API
- // =====================
- cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => {
- try {
- console.log('🔄 正在重新加载云数据库配置...');
- res.json({
- success: true,
- message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang'
- });
- } catch (error) {
- res.status(500).json({ success: false, message: error.message });
- }
- });
-
- console.log('='.repeat(50));
- console.log('🚀 后端服务已启动');
- console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
- console.log(cubeMode === 'cube'
- ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api`
- : '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
-
- // 测试数据库连接
- console.log('\n📦 数据库连接测试:');
- const [systemTest, businessTest, redisTest] = await Promise.all([
- withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }),
- withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }),
- withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }),
- ]);
- console.log(` [系统] ${systemTest.message}`);
- console.log(` [业务] ${businessTest.message}`);
- console.log(` [缓存] ${redisTest.message}`);
- console.log('='.repeat(50));
-
- if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
- console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
- return;
- }
-
- startScheduler();
- startStockSelectionSchedulers();
- startTaobaoProductEmbeddingSchedulers();
- startPddSkuDimensionSnapshotSchedulers();
- startPlanningMayProductSnapshotSchedulers();
- startPlanningDingWorkflowSyncSchedulers();
- startOpsBIncentiveSnapshotSchedulers();
- startGoodsFirstSeenScheduler();
- startAlibaba1688ImageSearchExpiryScheduler();
- startMkhReplenishAlertScheduler();
-
- // Keep the first selection-pool page request from paying the remote schema
- // inspection round-trip over the planning database tunnel.
- setTimeout(() => {
- warmSelectionFissionReadSchema()
- .then(() => console.log('[SelectionFission] 列表读取结构预热完成'))
- .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message));
- }, 1000);
-
- if (isStartupPrewarmEnabled()) {
- setTimeout(() => {
- if (typeof prewarmChannelHotData === 'function') {
- runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => {
- console.warn('⚠️ 渠道默认查询预热失败:', error.message);
- });
- }
- }, 8000);
-
- setTimeout(() => {
- if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') {
- runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => {
- console.warn('⚠️ 分析页默认查询预热失败:', error.message);
- });
- }
- }, 10000);
-
-
-
- if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') {
- const analysisPrewarmTimer = setInterval(() => {
- runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => {
- console.warn('⚠️ 分析页默认查询定时预热失败:', error.message);
- });
- }, 10 * 60 * 1000);
-
- if (typeof analysisPrewarmTimer.unref === 'function') {
- analysisPrewarmTimer.unref();
- }
- }
-
-
- } else {
- console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启');
- }
-
- if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') {
- if (typeof prewarmChannelIdDefaultSnapshots === 'function') {
- setTimeout(() => {
- runGuardedPrewarm(
- 'channel-id-analysis-resilience',
- () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true })
- ).catch((error) => {
- console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message);
- });
- }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS);
- }
-
- if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') {
- setTimeout(() => {
- runGuardedPrewarm(
- 'channel-id-dashboard-resilience',
- () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true })
- ).catch((error) => {
- console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message);
- });
- }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000);
- }
-
- setTimeout(() => {
- runGuardedPrewarm(
- 'channel-stock-monitor-resilience',
- () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true })
- ).catch((error) => {
- console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message);
- });
- }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS);
- }
-
- if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false'
- && typeof prewarmBrandAnalysisDefault === 'function') {
- setTimeout(() => {
- runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault())
- .then((result) => {
- console.log(
- `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}`
- );
- })
- .catch((error) => {
- console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message);
- });
- }, BRAND_ANALYSIS_PREWARM_DELAY_MS);
- }
-
- const cron = require('node-cron');
-
- if (typeof prewarmChannelHotData === 'function') {
- cron.schedule('0 1 * * *', async () => {
- console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...');
- await runGuardedPrewarm(
- 'channel-hot-data:daily',
- () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true })
- );
- }, {
- timezone: 'Asia/Shanghai',
- });
- console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行');
- }
-
- if (process.env.NODE_ENV === 'production') {
- const alertChecker = require('./services/alertChecker');
-
- cron.schedule('*/5 * * * *', async () => {
- console.log('🔔 执行预警检查...');
- await alertChecker.checkAllRules();
- });
- }
-}
-
-async function bootstrap() {
- const installStatus = await getInstallStatus();
- if (installStatus.required) {
- await startInstallServer(installStatus);
- return;
- }
-
- if (installStatus.databaseUnavailable) {
- console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`);
- await startServer({ skipLocalSchema: true });
- return;
- }
-
- await startServer();
-}
-
-bootstrap().catch((error) => {
- console.error('❌ 服务启动失败:', error);
- process.exit(1);
-});
-
-async function shutdownGracefully() {
- await Promise.allSettled([
- cloudDb.close(),
- jianSunDb.close(),
- database.close(),
- redis.close(),
- ]);
-}
-
-// 优雅关闭
-process.on('SIGTERM', async () => {
- console.log('正在关闭服务...');
- await shutdownGracefully();
- process.exit(0);
-});
-
-process.on('SIGINT', async () => {
- console.log('正在关闭服务...');
- await shutdownGracefully();
- process.exit(0);
-});
+// 后端服务入口
+const path = require('path');
+
+require('dotenv').config();
+require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
+
+const express = require('express');
+const cors = require('cors');
+const { CubejsServer } = require('@cubejs-backend/server');
+const mysql = require('mysql2/promise');
+
+const installRoutes = require('./api/install');
+const authMiddleware = require('./middleware/auth');
+const { createPageDataCache } = require('./middleware/pageDataCache');
+const adminRoutes = require('./api/admin');
+const planningUploadRoutes = require('./api/planningUpload');
+const planningMayProductPublicRoutes = require('./api/planningMayProductPublic');
+const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal');
+const presaleExternalRoutes = require('./api/presaleExternal');
+const delistExternalRoutes = require('./api/delistExternal');
+const campaignCloudRoutes = require('./api/campaignCloud');
+const desktopClientRoutes = require('./api/desktopClient');
+const desktopPresenceRoutes = require('./api/desktopPresence');
+const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks');
+const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal');
+const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus');
+const thirdPartySsoRoutes = require('./api/thirdPartySso');
+const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
+const channelRoutes = require('./api/channel');
+const analysisRoutes = require('./api/analysis');
+const productCodeBoardRoutes = require('./api/productCodeBoard');
+const productAnalysisRoutes = require('./api/productAnalysis');
+const marketingEventsRoutes = require('./api/marketingEvents');
+const promotionModuleRoutes = require('./api/promotionModule');
+const productMonitoringRoutes = require('./api/productMonitoring');
+const modularStockMonitorRoutes = require('./api/modularStockMonitor');
+const mkhBomRoutes = require('./api/mkhBom');
+const dailyInventoryRoutes = require('./api/dailyInventory');
+const database = require('./config/database');
+const cloudDb = require('./config/cloudDatabase');
+const jianSunDb = require('./config/jianSunDatabase');
+const redis = require('./config/redis');
+const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh');
+const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob');
+const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob');
+const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
+const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
+const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
+const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
+const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
+const {
+ startAlibaba1688ImageSearchExpiryScheduler,
+} = require('./jobs/alibaba1688ImageSearchExpiryJob');
+const {
+ startMkhReplenishAlertScheduler,
+} = require('./jobs/mkhReplenishAlertJob');
+const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
+const { ensureLocalSchema } = require('./services/localSchema');
+const {
+ prewarmChannelHotData,
+ prewarmChannelIdDefaultSnapshots,
+ prewarmChannelIdDashboardDefaultSnapshot,
+ prewarmChannelStockMonitorDashboardDefaultSnapshot,
+} = require('./services/channelAnalysis');
+const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis');
+const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard');
+const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService');
+const {
+ getPublicSystemConfigCache,
+ getStalePublicSystemConfigCache,
+ setPublicSystemConfigCache,
+} = require('./services/publicSystemConfigCache');
+const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility');
+const {
+ shouldRequireImageSearchCredentials,
+ verifyImageSearchCredentials,
+} = require('./services/imageSearchCredentialGuard');
+
+const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max(
+ Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60,
+ 60
+);
+const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max(
+ Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000,
+ 5 * 1000
+);
+const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max(
+ Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300,
+ 30
+) * 1000;
+const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max(
+ Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800,
+ 500
+);
+const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '')
+ .trim()
+ .toLowerCase() === 'true';
+
+function getBuildIdentity() {
+ return {
+ sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null,
+ sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null,
+ };
+}
+const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max(
+ Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000,
+ 10000
+);
+const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max(
+ Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000,
+ 10000
+);
+
+function shouldCachePlanningUploadResponse(body = {}) {
+ if (!body || body.error || body.success === false) {
+ return false;
+ }
+
+ const payload = body.data || body;
+ if (payload?.pending || payload?.cache?.backgroundRefresh) {
+ return false;
+ }
+
+ return true;
+}
+
+function shouldHandlePlanningUploadPageCache(req) {
+ // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。
+ // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。
+ // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。
+ // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。
+ // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。
+ if (
+ req.path === '/'
+ || req.path.startsWith('/table/may_product_data')
+ || req.path.startsWith('/analysis/may-channel-insight')
+ || req.path.startsWith('/analysis/ops-b-incentive')
+ || req.path.startsWith('/selection-fission')
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+const analysisPageCache = createPageDataCache({
+ namespace: 'analysis',
+ redisTtlSeconds: 300,
+});
+const channelPageCache = createPageDataCache({
+ namespace: 'channel',
+ redisTtlSeconds: 300,
+ // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。
+ shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path)
+ && !req.path.startsWith('/export-jobs'),
+});
+const planningUploadPageCache = createPageDataCache({
+ namespace: 'planning-upload',
+ version: 'v27-bypass-may-snapshot-pages-20260423',
+ redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS,
+ shouldHandleRequest: shouldHandlePlanningUploadPageCache,
+ shouldCacheBody: shouldCachePlanningUploadResponse,
+});
+let serverMode = 'install';
+
+function isStartupPrewarmEnabled() {
+ return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true';
+}
+
+function runGuardedPrewarm(label, handler) {
+ return runHeavyDatabaseTask(`prewarm:${label}`, handler);
+}
+
+function withTimeout(promise, timeoutMs, fallback) {
+ let timer = null;
+ return Promise.race([
+ promise.finally(() => {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }),
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(fallback), timeoutMs);
+ if (typeof timer.unref === 'function') {
+ timer.unref();
+ }
+ }),
+ ]);
+}
+
+function hasCompleteCloudEnvConfig() {
+ return cloudDb.hasCompleteEnvConfig();
+}
+
+function getCloudEnvConfig() {
+ const config = cloudDb.getEnvBusinessDatabaseConfig();
+ return {
+ driver: config.driver || 'mssql',
+ host: config.host,
+ port: config.port || 1433,
+ database: config.database,
+ user: config.user,
+ password: config.password,
+ ssl: Boolean(config.ssl),
+ };
+}
+
+function getBootstrapCloudConfig() {
+ return {
+ driver: 'mysql',
+ host: process.env.LOCAL_DB_HOST,
+ port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10),
+ database: process.env.LOCAL_DB_NAME,
+ user: process.env.LOCAL_DB_USER,
+ password: process.env.LOCAL_DB_PASSWORD,
+ ssl: false,
+ bootstrap: true,
+ };
+}
+
+// =====================
+// 从数据库获取云数据库配置
+// =====================
+async function getCloudDbConfig() {
+ let localPool;
+ try {
+ // 先连接本地数据库读取配置
+ localPool = mysql.createPool({
+ host: process.env.LOCAL_DB_HOST,
+ port: parseInt(process.env.LOCAL_DB_PORT || '3306'),
+ user: process.env.LOCAL_DB_USER,
+ password: process.env.LOCAL_DB_PASSWORD,
+ database: process.env.LOCAL_DB_NAME,
+ charset: 'utf8mb4',
+ waitForConnections: true,
+ connectionLimit: 2,
+ maxIdle: 2,
+ idleTimeout: 60000,
+ queueLimit: 10,
+ connectTimeout: 5000,
+ enableKeepAlive: true,
+ keepAliveInitialDelay: 0,
+ });
+
+ const [rows] = await localPool.query(
+ 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1',
+ ['active']
+ );
+
+ if (rows.length > 0) {
+ const config = rows[0];
+ if (config.use_env_config === 1 || config.use_env_config === true) {
+ if (hasCompleteCloudEnvConfig()) {
+ console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)');
+ return getCloudEnvConfig();
+ }
+ console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置');
+ }
+
+ console.log('📌 使用数据库配置(来自 database_configs 表)');
+ return {
+ driver: config.driver || 'mysql',
+ host: config.host,
+ port: config.port || 3306,
+ database: config.database_name,
+ user: config.username,
+ password: config.password,
+ ssl: config.ssl === 1,
+ };
+ }
+ } catch (error) {
+ console.warn('⚠️ 无法从数据库读取配置:', error.message);
+ } finally {
+ if (localPool) {
+ await localPool.end().catch((error) => {
+ console.warn('⚠️ 关闭系统库临时连接池失败:', error.message);
+ });
+ }
+ }
+
+ // 回退到环境变量配置
+ if (hasCompleteCloudEnvConfig()) {
+ console.log('📌 使用环境变量配置(CLOUD_DB_*)');
+ return getCloudEnvConfig();
+ }
+
+ console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式');
+ return getBootstrapCloudConfig();
+}
+
+function applyCommonMiddleware(app) {
+ app.set('trust proxy', 1);
+ app.use(cors());
+ app.use(express.json({
+ limit: '15mb',
+ verify: (req, _res, buffer) => {
+ req.rawBody = buffer.toString('utf8');
+ },
+ }));
+}
+
+function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) {
+ app.get('/api/admin/system-config', async (_req, res) => {
+ const now = Date.now();
+ const cachedConfig = getPublicSystemConfigCache(now);
+ if (cachedConfig) {
+ return res.json(cachedConfig);
+ }
+
+ try {
+ const configs = await Promise.race([
+ database.local.query('SELECT config_key, config_value, config_type FROM system_config'),
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS);
+ }),
+ ]);
+ const result = {};
+ filterPublicSystemConfigs(configs).forEach((config) => {
+ if (config.config_type === 'boolean') {
+ result[config.config_key] = config.config_value === 'true' || config.config_value === '1';
+ } else if (config.config_type === 'number') {
+ result[config.config_key] = Number(config.config_value);
+ } else {
+ result[config.config_key] = config.config_value;
+ }
+ });
+ setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now);
+ res.json(result);
+ } catch (error) {
+ const staleConfig = getStalePublicSystemConfigCache();
+ if (staleConfig) {
+ return res.json(staleConfig);
+ }
+
+ if (serverMode === 'install' || serverMode === 'database_unavailable') {
+ return res.json({
+ site_name: fallbackConfig.siteName,
+ site_description: fallbackConfig.siteDescription,
+ logo: '',
+ });
+ }
+
+ console.warn('获取系统配置超时或失败,返回兜底配置:', error.message);
+ return res.json({
+ site_name: fallbackConfig.siteName,
+ site_description: fallbackConfig.siteDescription,
+ logo: '',
+ });
+ }
+ });
+}
+
+function registerInstallModeRoutes(app, installStatus) {
+ const unavailableHandler = (_req, res) => {
+ res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' });
+ };
+
+ app.get('/health', async (_req, res) => {
+ const status = await getInstallStatus();
+ res.json({
+ status: 'install_required',
+ timestamp: new Date().toISOString(),
+ build: getBuildIdentity(),
+ install: status,
+ });
+ });
+
+ app.use('/api/install', installRoutes);
+ registerPublicSystemConfigRoute(app);
+
+ app.get('/api/auth/providers', (_req, res) => {
+ res.json({
+ authBaseUrl: '',
+ dingtalkEnabled: false,
+ googleEnabled: false,
+ installRequired: true,
+ });
+ });
+ app.get('/api/auth/dingtalk/login', unavailableHandler);
+ app.get('/api/auth/google/login', unavailableHandler);
+ app.post('/api/auth/login', unavailableHandler);
+ app.post('/api/auth/callback', unavailableHandler);
+ app.get('/api/auth/verify', (_req, res) => {
+ res.status(401).json({ valid: false, installRequired: true });
+ });
+
+ app.use('/api/admin', (_req, res) => {
+ res.status(503).json({
+ error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号',
+ });
+ });
+
+ console.log('='.repeat(50));
+ console.log('🛠️ 系统已进入首次安装模式');
+ console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
+ console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`);
+ console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`);
+ console.log('='.repeat(50));
+}
+
+async function startInstallServer(installStatus) {
+ serverMode = 'install';
+ const app = express();
+ applyCommonMiddleware(app);
+ registerInstallModeRoutes(app, installStatus);
+
+ const port = parseInt(process.env.PORT || '4000', 10);
+ await new Promise((resolve) => {
+ app.listen(port, resolve);
+ });
+}
+
+function registerDatabaseUnavailableRoutes(app, installStatus) {
+ const unavailableHandler = (_req, res) => {
+ res.status(503).json({
+ error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务',
+ databaseUnavailable: true,
+ installRequired: false,
+ });
+ };
+
+ app.get('/health', async (_req, res) => {
+ const status = await getInstallStatus();
+ res.status(503).json({
+ status: 'database_unavailable',
+ timestamp: new Date().toISOString(),
+ build: getBuildIdentity(),
+ install: status,
+ });
+ });
+
+ app.use('/api/install', installRoutes);
+ registerPublicSystemConfigRoute(app);
+
+ app.get('/api/auth/providers', (_req, res) => {
+ res.json({
+ authBaseUrl: '',
+ dingtalkEnabled: false,
+ googleEnabled: false,
+ installRequired: false,
+ databaseUnavailable: true,
+ });
+ });
+ app.get('/api/auth/verify', (_req, res) => {
+ res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false });
+ });
+ app.post('/api/auth/login', unavailableHandler);
+ app.post('/api/auth/callback', unavailableHandler);
+ app.get('/api/auth/dingtalk/login', unavailableHandler);
+ app.get('/api/auth/google/login', unavailableHandler);
+ app.use('/api', unavailableHandler);
+
+ console.log('='.repeat(50));
+ console.log('🚧 系统数据库暂时无法连接,已进入降级模式');
+ console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
+ console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`);
+ console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务');
+ console.log('='.repeat(50));
+}
+
+async function startDatabaseUnavailableServer(installStatus) {
+ serverMode = 'database_unavailable';
+ const app = express();
+ applyCommonMiddleware(app);
+ registerDatabaseUnavailableRoutes(app, installStatus);
+
+ const port = parseInt(process.env.PORT || '4000', 10);
+ await new Promise((resolve) => {
+ app.listen(port, resolve);
+ });
+}
+
+async function createFullModeApp(cloudConfig) {
+ const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql';
+ console.log(`📊 数据库类型: ${dbType}`);
+
+ const port = parseInt(process.env.PORT || '4000', 10);
+
+ if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') {
+ console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务');
+ const app = express();
+ await new Promise((resolve) => {
+ app.listen(port, resolve);
+ });
+
+ return {
+ app,
+ cubeMode: 'disabled',
+ cubeError: 'DISABLE_CUBEJS=true',
+ };
+ }
+
+ try {
+ const cubejsServer = new CubejsServer({
+ schemaPath: 'model',
+ dbType: dbType,
+ logger: (msg, params) => {
+ console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : '');
+ },
+ driverFactory: () => {
+ // 根据 driver 类型选择驱动
+ if (cloudConfig.driver === 'mssql') {
+ const MSSQLDriver = require('@cubejs-backend/mssql-driver');
+ return new MSSQLDriver({
+ server: cloudConfig.host,
+ port: cloudConfig.port,
+ database: cloudConfig.database,
+ user: cloudConfig.user,
+ password: cloudConfig.password,
+ options: {
+ encrypt: cloudConfig.ssl,
+ trustServerCertificate: true,
+ },
+ });
+ }
+
+ // 默认使用 MySQL
+ const MySQLDriver = require('@cubejs-backend/mysql-driver');
+ return new MySQLDriver({
+ database: cloudConfig.database,
+ host: cloudConfig.host,
+ port: cloudConfig.port,
+ user: cloudConfig.user,
+ password: cloudConfig.password,
+ ssl: cloudConfig.ssl,
+ });
+ },
+ apiSecret: process.env.CUBEJS_API_SECRET,
+ scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER,
+ jwt: {
+ key: process.env.CUBEJS_SECRET,
+ algorithms: ['HS256'],
+ },
+ });
+
+ const { app } = await cubejsServer.listen();
+ return {
+ app,
+ cubeMode: 'cube',
+ cubeError: null,
+ };
+ } catch (error) {
+ console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message);
+ const app = express();
+ await new Promise((resolve) => {
+ app.listen(port, resolve);
+ });
+
+ return {
+ app,
+ cubeMode: 'express-fallback',
+ cubeError: error.message,
+ };
+ }
+}
+
+// =====================
+// 启动服务
+// =====================
+async function startServer(options = {}) {
+ const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true';
+ const { skipLocalSchema = envSkipLocalSchema } = options;
+ serverMode = 'full';
+ if (skipLocalSchema) {
+ console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API');
+ } else {
+ await ensureLocalSchema();
+ }
+
+ if (shouldRequireImageSearchCredentials()) {
+ const credentialStatus = await verifyImageSearchCredentials();
+ console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`);
+ }
+
+ // 获取云数据库配置
+ const cloudConfig = await getCloudDbConfig();
+ if (cloudConfig.bootstrap) {
+ console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`);
+ } else {
+ console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`);
+ }
+
+ // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。
+ const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig);
+
+ applyCommonMiddleware(cubejsApp);
+
+ // =====================
+ // 健康检查 (多数据源)
+ // =====================
+ cubejsApp.get('/health', async (_req, res) => {
+ const [systemTest, businessTest, redisTest] = await Promise.all([
+ withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }),
+ withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }),
+ withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }),
+ ]);
+
+ res.json({
+ status: 'ok',
+ timestamp: new Date().toISOString(),
+ build: getBuildIdentity(),
+ databases: {
+ system: systemTest,
+ local: systemTest,
+ cloud: businessTest,
+ },
+ services: {
+ redis: redisTest,
+ cube: {
+ success: cubeMode === 'cube',
+ mode: cubeMode,
+ message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`,
+ },
+ },
+ });
+ });
+
+ // =====================
+ // 认证 API
+ // =====================
+ cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders);
+ cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin);
+ cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin);
+ cubejsApp.post('/api/auth/login', require('./api/auth').login);
+ cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview);
+ cubejsApp.post('/api/auth/callback', require('./api/auth').callback);
+ cubejsApp.get('/api/auth/verify', require('./api/auth').verify);
+
+ // =====================
+ // 公开访问的 API(无需认证)
+ // =====================
+ cubejsApp.use('/api/install', installRoutes);
+ cubejsApp.use('/api/sso', thirdPartySsoRoutes);
+ cubejsApp.use('/api/desktop-client', desktopClientRoutes);
+ cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes);
+ cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes);
+ cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes);
+ cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes);
+ cubejsApp.use('/api/campaign', campaignCloudRoutes);
+ cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes);
+ cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes);
+ cubejsApp.use('/api/external/presale', presaleExternalRoutes);
+
+ // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo)
+ registerPublicSystemConfigRoute(cubejsApp);
+
+ // =====================
+ // 需要认证的 API
+ // =====================
+ cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes);
+ cubejsApp.use('/api/admin', authMiddleware, adminRoutes);
+ cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
+ cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
+ cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
+ cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
+ cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
+ cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
+ cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
+ cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
+ cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
+ cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
+ cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
+ cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
+
+ // =====================
+ // 数据分析 API(公开访问,可根据需要添加认证)
+ // =====================
+ cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes);
+
+ // =====================
+ // 数据刷新 API(需要认证)
+ // =====================
+ cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => {
+ try {
+ const { date } = req.body;
+ console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`);
+ const result = await manualRefresh(date);
+ res.json({ success: true, ...result });
+ } catch (error) {
+ res.status(500).json({ success: false, error: error.message });
+ }
+ });
+
+ // =====================
+ // 重新加载云数据库配置 API
+ // =====================
+ cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => {
+ try {
+ console.log('🔄 正在重新加载云数据库配置...');
+ res.json({
+ success: true,
+ message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang'
+ });
+ } catch (error) {
+ res.status(500).json({ success: false, message: error.message });
+ }
+ });
+
+ console.log('='.repeat(50));
+ console.log('🚀 后端服务已启动');
+ console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
+ console.log(cubeMode === 'cube'
+ ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api`
+ : '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
+
+ // 测试数据库连接
+ console.log('\n📦 数据库连接测试:');
+ const [systemTest, businessTest, redisTest] = await Promise.all([
+ withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }),
+ withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }),
+ withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }),
+ ]);
+ console.log(` [系统] ${systemTest.message}`);
+ console.log(` [业务] ${businessTest.message}`);
+ console.log(` [缓存] ${redisTest.message}`);
+ console.log('='.repeat(50));
+
+ if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
+ console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
+ return;
+ }
+
+ startScheduler();
+ startStockSelectionSchedulers();
+ startTaobaoProductEmbeddingSchedulers();
+ startPddSkuDimensionSnapshotSchedulers();
+ startPlanningMayProductSnapshotSchedulers();
+ startPlanningDingWorkflowSyncSchedulers();
+ startOpsBIncentiveSnapshotSchedulers();
+ startGoodsFirstSeenScheduler();
+ startAlibaba1688ImageSearchExpiryScheduler();
+ startMkhReplenishAlertScheduler();
+
+ // Keep the first selection-pool page request from paying the remote schema
+ // inspection round-trip over the planning database tunnel.
+ setTimeout(() => {
+ warmSelectionFissionReadSchema()
+ .then(() => console.log('[SelectionFission] 列表读取结构预热完成'))
+ .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message));
+ }, 1000);
+
+ if (isStartupPrewarmEnabled()) {
+ setTimeout(() => {
+ if (typeof prewarmChannelHotData === 'function') {
+ runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => {
+ console.warn('⚠️ 渠道默认查询预热失败:', error.message);
+ });
+ }
+ }, 8000);
+
+ setTimeout(() => {
+ if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') {
+ runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => {
+ console.warn('⚠️ 分析页默认查询预热失败:', error.message);
+ });
+ }
+ }, 10000);
+
+
+
+ if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') {
+ const analysisPrewarmTimer = setInterval(() => {
+ runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => {
+ console.warn('⚠️ 分析页默认查询定时预热失败:', error.message);
+ });
+ }, 10 * 60 * 1000);
+
+ if (typeof analysisPrewarmTimer.unref === 'function') {
+ analysisPrewarmTimer.unref();
+ }
+ }
+
+
+ } else {
+ console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启');
+ }
+
+ if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') {
+ if (typeof prewarmChannelIdDefaultSnapshots === 'function') {
+ setTimeout(() => {
+ runGuardedPrewarm(
+ 'channel-id-analysis-resilience',
+ () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true })
+ ).catch((error) => {
+ console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message);
+ });
+ }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS);
+ }
+
+ if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') {
+ setTimeout(() => {
+ runGuardedPrewarm(
+ 'channel-id-dashboard-resilience',
+ () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true })
+ ).catch((error) => {
+ console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message);
+ });
+ }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000);
+ }
+
+ setTimeout(() => {
+ runGuardedPrewarm(
+ 'channel-stock-monitor-resilience',
+ () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true })
+ ).catch((error) => {
+ console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message);
+ });
+ }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS);
+ }
+
+ if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false'
+ && typeof prewarmBrandAnalysisDefault === 'function') {
+ setTimeout(() => {
+ runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault())
+ .then((result) => {
+ console.log(
+ `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}`
+ );
+ })
+ .catch((error) => {
+ console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message);
+ });
+ }, BRAND_ANALYSIS_PREWARM_DELAY_MS);
+ }
+
+ const cron = require('node-cron');
+
+ if (typeof prewarmChannelHotData === 'function') {
+ cron.schedule('0 1 * * *', async () => {
+ console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...');
+ await runGuardedPrewarm(
+ 'channel-hot-data:daily',
+ () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true })
+ );
+ }, {
+ timezone: 'Asia/Shanghai',
+ });
+ console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行');
+ }
+
+ if (process.env.NODE_ENV === 'production') {
+ const alertChecker = require('./services/alertChecker');
+
+ cron.schedule('*/5 * * * *', async () => {
+ console.log('🔔 执行预警检查...');
+ await alertChecker.checkAllRules();
+ });
+ }
+}
+
+async function bootstrap() {
+ const installStatus = await getInstallStatus();
+ if (installStatus.required) {
+ await startInstallServer(installStatus);
+ return;
+ }
+
+ if (installStatus.databaseUnavailable) {
+ console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`);
+ await startServer({ skipLocalSchema: true });
+ return;
+ }
+
+ await startServer();
+}
+
+bootstrap().catch((error) => {
+ console.error('❌ 服务启动失败:', error);
+ process.exit(1);
+});
+
+async function shutdownGracefully() {
+ await Promise.allSettled([
+ cloudDb.close(),
+ jianSunDb.close(),
+ database.close(),
+ redis.close(),
+ ]);
+}
+
+// 优雅关闭
+process.on('SIGTERM', async () => {
+ console.log('正在关闭服务...');
+ await shutdownGracefully();
+ process.exit(0);
+});
+
+process.on('SIGINT', async () => {
+ console.log('正在关闭服务...');
+ await shutdownGracefully();
+ process.exit(0);
+});
diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js
new file mode 100644
index 0000000..e844361
--- /dev/null
+++ b/frontend/src/api/dailyInventory.js
@@ -0,0 +1,9 @@
+import request from './request';
+
+const LIST_TIMEOUT_MS = 5 * 60 * 1000;
+
+// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量
+export const getDailyInventory = () => request.get(
+ '/api/daily-inventory',
+ { timeout: LIST_TIMEOUT_MS }
+);
diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js
index 63a6520..5257bb9 100644
--- a/frontend/src/config/sidebarNavigation.js
+++ b/frontend/src/config/sidebarNavigation.js
@@ -1,195 +1,201 @@
-export const DEFAULT_SIDEBAR_CONFIG = {
- collapsedByDefault: false,
- darkModeByDefault: false,
- entries: [
- { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
- { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
- { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
- {
- key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
- children: [
- { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
- { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
- ],
- },
- {
- key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
- children: [
- { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
- ],
- },
- { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
- {
- key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
- children: [
- { key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
- { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
- ],
- },
- {
- key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
- children: [
- { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
- { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
- { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
- ],
- },
- ],
-};
-
-const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
-
-const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
- entry.key,
- ...flattenEntryKeys(entry.children || []),
-]).filter(Boolean);
-
-const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
- entry.routeKey,
- ...flattenRouteKeys(entry.children || []),
-]).filter(Boolean);
-
-const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
-const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
-
-// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
-const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
-const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
-const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
-// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
-const extractPromotedTopLevelEntries = (entries = []) => {
- const promoted = [];
- const nextEntries = entries.map((entry) => {
- if (!Array.isArray(entry?.children)) return entry;
- const children = [];
- entry.children.forEach((child) => {
- if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
- promoted.push(child);
- } else {
- children.push(child);
- }
- });
- return children.length === entry.children.length ? entry : { ...entry, children };
- });
- return { nextEntries, promoted };
-};
-
-const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
- const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
- const configuredKeys = new Set();
-
- const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
- const merged = { ...defaultEntry, ...configuredEntry };
- if (defaultEntry.type) {
- merged.type = defaultEntry.type;
- }
- if (defaultEntry.routeKey) {
- merged.routeKey = defaultEntry.routeKey;
- }
- if (typeof defaultEntry.requiresAdmin !== 'undefined') {
- merged.requiresAdmin = defaultEntry.requiresAdmin;
- }
- if (
- defaultEntry.key === 'ops-b-incentive'
- && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
- ) {
- merged.label = defaultEntry.label;
- }
- if (
- defaultEntry.key === 'ops-b-incentive'
- && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
- ) {
- merged.order = defaultEntry.order;
- }
- const hasDefaultChildren = Array.isArray(defaultEntry.children);
- const hasConfiguredChildren = Array.isArray(configuredEntry.children);
-
- if (hasDefaultChildren || hasConfiguredChildren) {
- if (hasDefaultChildren) {
- merged.type = defaultEntry.type;
- } else if (defaultEntry.routeKey) {
- merged.type = defaultEntry.type || 'item';
- delete merged.children;
- delete merged.defaultExpanded;
- return merged;
- }
- merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
- }
-
- return merged;
- };
-
- const configuredMerged = configuredEntries.map((entry) => {
- if (entry?.key) {
- configuredKeys.add(entry.key);
- }
-
- const defaultEntry = defaultByKey.get(entry?.key);
- return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
- });
-
- const missingDefaults = defaultEntries
- .filter((entry) => entry?.key && !configuredKeys.has(entry.key))
- .map((entry) => mergeEntry(entry, {}));
-
- return [...configuredMerged, ...missingDefaults];
-};
-
-const mergeSidebarConfigWithDefaults = (config) => {
- const defaults = cloneDefaultConfig();
- const nestedEntries = (config?.entries || []);
- const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
- const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
- const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
-
- return {
- ...defaults,
- ...config,
- entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
- };
-};
-
-const pruneSidebarEntry = (entry) => {
- if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
- return null;
- }
-
- const children = Array.isArray(entry.children)
- ? entry.children.map(pruneSidebarEntry).filter(Boolean)
- : undefined;
- const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
- const hasAllowedChildren = Array.isArray(children) && children.length > 0;
- const keepEntry = hasAllowedRoute || hasAllowedChildren;
-
- if (!keepEntry) {
- return null;
- }
-
- return {
- ...entry,
- ...(Array.isArray(entry.children) ? { children } : {}),
- };
-};
-
-const removeRetiredEntries = (config) => ({
- ...config,
- entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
-});
-
-export const parseSidebarConfig = (value) => {
- if (!value) return cloneDefaultConfig();
-
- try {
- const parsed = typeof value === 'string' ? JSON.parse(value) : value;
- if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
- return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
- } catch {
- return cloneDefaultConfig();
- }
-};
-
-export const sortSidebarEntries = (entries = []) => (
- [...entries]
- .filter((entry) => entry?.visible !== false)
- .sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
-);
+export const DEFAULT_SIDEBAR_CONFIG = {
+ collapsedByDefault: false,
+ darkModeByDefault: false,
+ entries: [
+ { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
+ { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
+ { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
+ {
+ key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
+ children: [
+ { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
+ { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
+ ],
+ },
+ {
+ key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
+ children: [
+ { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
+ ],
+ },
+ { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
+ {
+ key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
+ children: [
+ { key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
+ { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
+ ],
+ },
+ {
+ key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
+ children: [
+ { key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
+ ],
+ },
+ {
+ key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
+ children: [
+ { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
+ { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
+ { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
+ ],
+ },
+ ],
+};
+
+const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
+
+const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
+ entry.key,
+ ...flattenEntryKeys(entry.children || []),
+]).filter(Boolean);
+
+const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
+ entry.routeKey,
+ ...flattenRouteKeys(entry.children || []),
+]).filter(Boolean);
+
+const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
+const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
+
+// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
+const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
+const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
+const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
+// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
+const extractPromotedTopLevelEntries = (entries = []) => {
+ const promoted = [];
+ const nextEntries = entries.map((entry) => {
+ if (!Array.isArray(entry?.children)) return entry;
+ const children = [];
+ entry.children.forEach((child) => {
+ if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
+ promoted.push(child);
+ } else {
+ children.push(child);
+ }
+ });
+ return children.length === entry.children.length ? entry : { ...entry, children };
+ });
+ return { nextEntries, promoted };
+};
+
+const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
+ const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
+ const configuredKeys = new Set();
+
+ const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
+ const merged = { ...defaultEntry, ...configuredEntry };
+ if (defaultEntry.type) {
+ merged.type = defaultEntry.type;
+ }
+ if (defaultEntry.routeKey) {
+ merged.routeKey = defaultEntry.routeKey;
+ }
+ if (typeof defaultEntry.requiresAdmin !== 'undefined') {
+ merged.requiresAdmin = defaultEntry.requiresAdmin;
+ }
+ if (
+ defaultEntry.key === 'ops-b-incentive'
+ && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
+ ) {
+ merged.label = defaultEntry.label;
+ }
+ if (
+ defaultEntry.key === 'ops-b-incentive'
+ && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
+ ) {
+ merged.order = defaultEntry.order;
+ }
+ const hasDefaultChildren = Array.isArray(defaultEntry.children);
+ const hasConfiguredChildren = Array.isArray(configuredEntry.children);
+
+ if (hasDefaultChildren || hasConfiguredChildren) {
+ if (hasDefaultChildren) {
+ merged.type = defaultEntry.type;
+ } else if (defaultEntry.routeKey) {
+ merged.type = defaultEntry.type || 'item';
+ delete merged.children;
+ delete merged.defaultExpanded;
+ return merged;
+ }
+ merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
+ }
+
+ return merged;
+ };
+
+ const configuredMerged = configuredEntries.map((entry) => {
+ if (entry?.key) {
+ configuredKeys.add(entry.key);
+ }
+
+ const defaultEntry = defaultByKey.get(entry?.key);
+ return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
+ });
+
+ const missingDefaults = defaultEntries
+ .filter((entry) => entry?.key && !configuredKeys.has(entry.key))
+ .map((entry) => mergeEntry(entry, {}));
+
+ return [...configuredMerged, ...missingDefaults];
+};
+
+const mergeSidebarConfigWithDefaults = (config) => {
+ const defaults = cloneDefaultConfig();
+ const nestedEntries = (config?.entries || []);
+ const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
+ const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
+ const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
+
+ return {
+ ...defaults,
+ ...config,
+ entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
+ };
+};
+
+const pruneSidebarEntry = (entry) => {
+ if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
+ return null;
+ }
+
+ const children = Array.isArray(entry.children)
+ ? entry.children.map(pruneSidebarEntry).filter(Boolean)
+ : undefined;
+ const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
+ const hasAllowedChildren = Array.isArray(children) && children.length > 0;
+ const keepEntry = hasAllowedRoute || hasAllowedChildren;
+
+ if (!keepEntry) {
+ return null;
+ }
+
+ return {
+ ...entry,
+ ...(Array.isArray(entry.children) ? { children } : {}),
+ };
+};
+
+const removeRetiredEntries = (config) => ({
+ ...config,
+ entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
+});
+
+export const parseSidebarConfig = (value) => {
+ if (!value) return cloneDefaultConfig();
+
+ try {
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
+ if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
+ return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
+ } catch {
+ return cloneDefaultConfig();
+ }
+};
+
+export const sortSidebarEntries = (entries = []) => (
+ [...entries]
+ .filter((entry) => entry?.visible !== false)
+ .sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
+);
diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js
new file mode 100644
index 0000000..c66257f
--- /dev/null
+++ b/frontend/src/constants/dailyInventory.js
@@ -0,0 +1,61 @@
+/**
+ * 每日进销存 —— 列定义
+ *
+ * 表头取自桌面《库存监控.xlsx》,顺序与表格一致。
+ * source 标记该列数据来源,便于在页面上如实标注口径:
+ * - baseline:22 号基准值(业务库无对应单据表,无法还原)
+ * - runtime:运行时从业务库实时取数
+ */
+
+export const COLUMN_SOURCE = {
+ baseline: 'baseline',
+ runtime: 'runtime',
+};
+
+/** 表格存在两个名为「期初」的列,第二个加后缀区分 */
+export const DAILY_INVENTORY_COLUMNS = [
+ { key: 'warehouse', label: '仓库', source: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' },
+ { key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' },
+ { key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' },
+ { key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' },
+ { key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' },
+ { key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' },
+ { key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' },
+ { key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' },
+ { key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' },
+ { key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' },
+ { key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' },
+ { key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' },
+ { key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' },
+ { key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' },
+ { key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' },
+ { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' },
+ { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' },
+ { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' },
+ { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' },
+ { key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' },
+ { key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' },
+];
+
+/** 口径说明弹框文案 */
+export const FORMULA_DESCRIPTION = [
+ '数据来源说明:',
+ '· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:',
+ ' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,',
+ ' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。',
+ '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。',
+ '· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。',
+ '',
+ '算式:',
+ '· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入',
+ ' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库',
+ '· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%',
+ ' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock',
+ '· 库存分级(库龄 + 周转率双维度):',
+ ' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警',
+ ' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流',
+ ' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流',
+ ' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞',
+ ' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞',
+ ' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸',
+];
diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js
index 4545a75..3e72ea9 100644
--- a/frontend/src/constants/sidebarPages.js
+++ b/frontend/src/constants/sidebarPages.js
@@ -1,12 +1,13 @@
-const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
-const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
-
-export const SIDEBAR_PAGES = [
- { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
- { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
-];
-
-export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
- acc[item.pageKey] = item;
- return acc;
-}, {});
+const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
+const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
+
+export const SIDEBAR_PAGES = [
+ { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
+ { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
+ { pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
+];
+
+export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
+ acc[item.pageKey] = item;
+ return acc;
+}, {});
diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx
index 6cb7ddc..eabe42e 100644
--- a/frontend/src/layouts/BasicLayout.jsx
+++ b/frontend/src/layouts/BasicLayout.jsx
@@ -1,1325 +1,1326 @@
-import React, { useEffect, useMemo, useState } from 'react';
-import { Outlet, useLocation, useNavigate } from 'react-router-dom';
-import {
- AppstoreOutlined,
- CloudOutlined,
- DashboardOutlined,
- DesktopOutlined,
- DownOutlined,
- ExperimentOutlined,
- FundProjectionScreenOutlined,
- LineChartOutlined,
- LogoutOutlined,
- MenuFoldOutlined,
- MenuUnfoldOutlined,
- MessageOutlined,
- MoonOutlined,
- RightOutlined,
- RobotOutlined,
- SettingOutlined,
- ShoppingOutlined,
- TableOutlined,
- TeamOutlined,
- UserOutlined,
- SunOutlined,
-} from '@ant-design/icons';
-import { Dropdown, Switch } from 'antd';
-import { verifyToken } from '../api/auth';
-import { getSetupStatus, getSystemConfig } from '../api/admin';
-import { getInstallStatus } from '../api/install';
-import ErrorBoundary from '../components/ErrorBoundary';
-import { AppShellSkeleton } from '../components/AppLoadingSkeleton';
-import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation';
-import {
- canRestoreDesktopSessionImmediately,
- isDesktopEmbedSession,
- markDesktopEmbedSession,
- requestDesktopSessionRenewal,
-} from '../utils/desktopEmbedSession';
-
-const AUTH_USER_STORAGE_KEY = 'auth_user';
-const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode';
-const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary';
-const PLANNING_HOME_URL = '/planning/dev-style-preview';
-const FALLBACK_SITE_NAME = '拼多多自动化运营平台';
-const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png';
-const readSidebarDarkModePreference = () => {
- try {
- const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY);
- if (value === 'dark') return true;
- if (value === 'light') return false;
- } catch {
- // Local persistence is optional in restricted browser contexts.
- }
- return null;
-};
-
-const SIDEBAR_ICON_MAP = {
- appstore: AppstoreOutlined,
- cloud: CloudOutlined,
- dashboard: DashboardOutlined,
- desktop: DesktopOutlined,
- experiment: ExperimentOutlined,
- 'line-chart': LineChartOutlined,
- message: MessageOutlined,
- projection: FundProjectionScreenOutlined,
- robot: RobotOutlined,
- setting: SettingOutlined,
- shopping: ShoppingOutlined,
- table: TableOutlined,
- team: TeamOutlined,
-};
-
-const SIDEBAR_ROUTE_MAP = {
- 'dashboard-preview': { path: '/planning/dev-style-preview' },
- 'marketing-events': {
- path: '/planning/operation/marketing-events',
- activePath: '/planning/operation/marketing-events',
- matchMode: 'prefix',
- },
- 'promotion-module': { path: '/planning/operation/promotion-module' },
- 'product-monitoring': { path: '/planning/operation/product-monitoring' },
- 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
- 'product-analysis': { path: '/planning/operation/product-analysis' },
- 'work-orders': { path: '/planning/operation/work-orders' },
- 'modular-stock-monitor-accessory': {
- path: '/planning/operation/modular-stock-monitor',
- activePath: '/planning/operation/modular-stock-monitor',
- matchMode: 'prefix',
- },
- 'mkh-bom-data': {
- path: '/planning/operation/mkh-bom-data',
- activePath: '/planning/operation/mkh-bom-data',
- matchMode: 'prefix',
- },
- settings: { path: '/settings' },
- 'online-desktop-users': { path: '/settings/online-desktop-users' },
- 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
-};
-
-
-const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => {
- if (entry.requiresAdmin && !canUseSettings) return [];
- if (entry.type === 'section') return [{ ...entry }];
-
- const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {};
- const routeMatches = (itemRoute = {}, pathname = '') => {
- const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : [];
- if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
- return false;
- }
-
- const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : [];
- if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
- return true;
- }
-
- const matchPath = itemRoute.activePath || itemRoute.path;
- if (!matchPath) return false;
- return itemRoute.matchMode === 'prefix'
- ? pathname === matchPath || pathname.startsWith(`${matchPath}/`)
- : pathname === matchPath;
- };
- const buildItem = (item) => {
- if (item.requiresAdmin && !canUseSettings) return null;
- const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {};
- return {
- ...item,
- icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined,
- path: itemRoute.path,
- external: Boolean(itemRoute.externalUrl),
- onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined,
- match: (path) => routeMatches(itemRoute, path),
- };
- };
-
- const normalized = {
- ...entry,
- icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined,
- path: route.path,
- external: Boolean(route.externalUrl),
- onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined,
- match: (path) => routeMatches(route, path),
- };
-
- if (entry.type === 'group') {
- normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean);
- }
-
- return [normalized];
-});
-
-function parseJwtPayload(token) {
- if (!token) {
- return null;
- }
-
- try {
- const [, payload] = token.split('.');
- if (!payload) {
- return null;
- }
-
- const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
- const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=');
- return JSON.parse(window.atob(padded));
- } catch {
- return null;
- }
-}
-
-function isExpiredJwtPayload(payload) {
- const exp = Number(payload?.exp || 0);
- return exp > 0 && Math.floor(Date.now() / 1000) >= exp;
-}
-
-function readStoredAuthUser(tokenSub) {
- try {
- const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY);
- if (!raw) {
- return null;
- }
-
- const storedUser = JSON.parse(raw);
- const storedSub = storedUser?.sub ?? storedUser?.id;
- return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null;
- } catch {
- return null;
- }
-}
-
-function clearAuthSession() {
- localStorage.removeItem('token');
- localStorage.removeItem(AUTH_USER_STORAGE_KEY);
-}
-
-function looksLikeMojibake(text) {
- return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text);
-}
-
-function repairMojibake(text) {
- if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') {
- return text;
- }
-
- try {
- const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff));
- const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim();
- return repaired || text;
- } catch {
- return text;
- }
-}
-
-function normalizeDisplayName(...candidates) {
- const rawName = candidates.find((item) => typeof item === 'string' && item.trim());
- return repairMojibake(rawName?.trim() || '个人中心');
-}
-
-function buildFallbackUserFromToken(token) {
- const payload = parseJwtPayload(token);
- if (!payload?.sub || isExpiredJwtPayload(payload)) {
- return null;
- }
-
- const storedUser = readStoredAuthUser(payload.sub);
- return {
- sub: payload.sub,
- username: payload.username || '',
- name: payload.name || payload.username || '当前用户',
- email: payload.email || '',
- role: payload.role || storedUser?.role || 'user',
- picture: payload.picture || storedUser?.picture || '',
- authSource: payload.authSource || storedUser?.authSource || 'local',
- departmentId: payload.departmentId ?? storedUser?.departmentId ?? null,
- departmentName: payload.departmentName || storedUser?.departmentName || '',
- allowedPageKeys: Array.isArray(payload.allowedPageKeys)
- ? payload.allowedPageKeys
- : Array.isArray(storedUser?.allowedPageKeys)
- ? storedUser.allowedPageKeys
- : [],
- };
-}
-
-function canFallbackToJwtPayload(error, fallbackUser) {
- if (!fallbackUser) {
- return false;
- }
-
- if (fallbackUser.authSource === 'local-preview') {
- return true;
- }
-
- const status = Number(error?.response?.status || 0);
- return !status || status >= 500;
-}
-
-const BasicLayout = () => {
- const location = useLocation();
- const navigate = useNavigate();
- const [user, setUser] = useState(null);
- const [authReady, setAuthReady] = useState(false);
- const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO });
- const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG);
- const [sidebarCollapsed, setSidebarCollapsed] = useState(
- () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault
- );
- const [sidebarDarkMode, setSidebarDarkMode] = useState(
- () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault
- );
- const [expandedSidebarKeys, setExpandedSidebarKeys] = useState(
- DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key)
- );
-
- const pathname = location.pathname;
- const isImmersivePlanningRoute = false;
- const canUseSettings = user?.role === 'admin';
- const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email);
- const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO;
-
- const sidebarSections = useMemo(
- () => buildSidebarSections(sidebarConfig, canUseSettings),
- [canUseSettings, sidebarConfig]
- );
- useEffect(() => {
- const loadSiteConfig = async () => {
- try {
- const config = await getSystemConfig();
- const nextSiteName = config?.site_name || FALLBACK_SITE_NAME;
- const nextLogo = config?.logo || FALLBACK_LOGO;
- const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation);
-
- setSiteConfig({ siteName: nextSiteName, logo: nextLogo });
- setSidebarConfig(nextSidebarConfig);
- setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault));
- setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault));
- setExpandedSidebarKeys(
- nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key)
- );
- document.title = nextSiteName;
-
- let link = document.querySelector("link[rel*='icon']");
- if (!link) {
- link = document.createElement('link');
- link.rel = 'icon';
- document.head.appendChild(link);
- }
- link.href = nextLogo;
- } catch {
- document.title = FALLBACK_SITE_NAME;
- }
- };
-
- loadSiteConfig();
- }, []);
-
- useEffect(() => {
- const handleSiteConfigUpdated = (event) => {
- const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME;
- const nextLogo = event.detail?.logo;
-
- setSiteConfig((current) => ({
- siteName: nextSiteName,
- logo: nextLogo || current.logo,
- }));
- document.title = nextSiteName;
-
- if (nextLogo) {
- let link = document.querySelector("link[rel*='icon']");
- if (!link) {
- link = document.createElement('link');
- link.rel = 'icon';
- document.head.appendChild(link);
- }
- link.href = nextLogo;
- }
- };
-
- window.addEventListener('site-config-updated', handleSiteConfigUpdated);
- return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated);
- }, []);
-
- useEffect(() => {
- const checkAuth = async () => {
- markDesktopEmbedSession();
- const token = localStorage.getItem('token');
- const fallbackUser = buildFallbackUserFromToken(token);
-
- if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) {
- setUser(fallbackUser);
- setAuthReady(true);
-
- try {
- const verification = await verifyToken();
- if (!verification?.valid || !verification.user) {
- clearAuthSession();
- requestDesktopSessionRenewal('web-session-invalid');
- return;
- }
-
- const resolvedUser = verification.user;
- localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser));
- setUser(resolvedUser);
-
- if (resolvedUser.role === 'admin') {
- const setup = await getSetupStatus().catch((error) => {
- console.warn('Setup status unavailable, continue with current session:', error);
- return null;
- });
- if (setup?.shouldShowWizard) {
- navigate('/setup', { replace: true });
- }
- }
- } catch (error) {
- if (!canFallbackToJwtPayload(error, fallbackUser)) {
- clearAuthSession();
- requestDesktopSessionRenewal('web-session-invalid');
- }
- }
- return;
- }
-
- try {
- const installStatusPromise = getInstallStatus().catch(() => ({ required: false }));
- const verificationPromise = verifyToken()
- .then((res) => ({ res, error: null }))
- .catch((error) => ({ res: null, error }));
- const setupStatusPromise = fallbackUser?.role === 'admin'
- ? getSetupStatus().catch((error) => {
- console.warn('Setup status unavailable, continue with current session:', error);
- return null;
- })
- : null;
- const [installStatus, verification] = await Promise.all([
- installStatusPromise,
- verificationPromise,
- ]);
- if (installStatus?.required && !fallbackUser) {
- clearAuthSession();
- navigate('/install', { replace: true });
- return;
- }
-
- let resolvedUser = null;
- const verifyError = verification.error;
- if (verification.res?.valid) {
- resolvedUser = verification.res.user;
- } else if (verifyError) {
- console.warn('Token verification failed:', verifyError);
- }
-
- if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) {
- resolvedUser = fallbackUser;
- }
-
- if (resolvedUser) {
- localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser));
- if (resolvedUser.role === 'admin') {
- try {
- const setup = setupStatusPromise
- ? await setupStatusPromise
- : await getSetupStatus();
- if (setup?.shouldShowWizard) {
- navigate('/setup', { replace: true });
- return;
- }
- } catch (error) {
- console.warn('Setup status unavailable, continue with current session:', error);
- }
- }
-
- setUser(resolvedUser);
- } else if (installStatus?.required) {
- clearAuthSession();
- navigate('/install', { replace: true });
- } else {
- clearAuthSession();
- if (!requestDesktopSessionRenewal('web-session-missing')) {
- navigate('/login');
- }
- }
- } catch (error) {
- console.error(error);
- if (fallbackUser) {
- setUser(fallbackUser);
- } else {
- clearAuthSession();
- if (!requestDesktopSessionRenewal('web-session-invalid')) {
- navigate('/login');
- }
- }
- } finally {
- setAuthReady(true);
- }
- };
-
- checkAuth();
- }, [navigate]);
-
- useEffect(() => {
- if (!authReady || !user) {
- return;
- }
-
- if (pathname === '/' || pathname === '/dashboard') {
- navigate(PLANNING_HOME_URL, { replace: true });
- return;
- }
-
- if (pathname.startsWith('/settings') && !canUseSettings) {
- navigate(PLANNING_HOME_URL, { replace: true });
- return;
- }
-
- const isAllowedPath = (
- pathname === '/planning'
- || pathname.startsWith('/planning/')
- || pathname === '/settings'
- || pathname.startsWith('/settings/')
- );
-
- if (!isAllowedPath) {
- navigate(PLANNING_HOME_URL, { replace: true });
- }
- }, [authReady, canUseSettings, navigate, pathname, user]);
-
- const handleLogout = () => {
- clearAuthSession();
- navigate('/login');
- };
-
- const userMenu = [
- canUseSettings
- ? {
- key: 'settings',
- icon: