Merge remote-tracking branch 'origin/main' into codex/modular-stock-monitor-0924

# Conflicts:
#	backend/src/config/cloudDatabase.js
#	backend/src/index.js
#	frontend/src/config/sidebarNavigation.js
#	frontend/src/layouts/BasicLayout.jsx
#	frontend/src/router/index.jsx
This commit is contained in:
wangjie 2026-09-24 15:39:41 +08:00
commit 43d9c3a2d1
52 changed files with 13731 additions and 2954 deletions

4
.gitignore vendored
View File

@ -58,3 +58,7 @@ tmall-board-speed-check.json
.remote-access.backup-*/
.workbuddy/
**/node_modules
# Local verification artifacts
/outputs/
.git-status.txt

View File

@ -6,8 +6,9 @@
- 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。
- 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。
- 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;默认由非提交者审核。仓库所有者或管理员明确授权某个 PR 合并时,该书面授权可以代替独立审核,但仍必须同步最新主分支、验证最终提交并保留授权记录。
- 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。
- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。
- 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。
- 最终交付报告实际分支、提交 SHA、验证结果及 PR 状态,不能将“已推送”报告为“已合并”。

View File

@ -18,6 +18,8 @@ npm run remote:dev
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
## 验证与构建
```bash

View File

@ -28,6 +28,7 @@
"multer": "^2.1.1",
"mysql2": "^3.6.5",
"node-cron": "^3.0.3",
"pg": "8.17.2",
"playwright": "^1.60.0",
"sharp": "^0.33.5",
"uuid": "^9.0.1",

View File

@ -32,6 +32,7 @@
"multer": "^2.1.1",
"mysql2": "^3.6.5",
"node-cron": "^3.0.3",
"pg": "8.17.2",
"playwright": "^1.60.0",
"sharp": "^0.33.5",
"uuid": "^9.0.1",

View File

@ -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); });

View File

@ -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); });

View File

@ -98,10 +98,6 @@ const {
testSelectionFissionAiConfig,
} = require('../services/selectionFissionAiSettings');
const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService');
const {
getProductTagConfig,
saveProductTagConfig,
} = require('../services/productTagConfigService');
const backfillJobs = new Map();
const productCodeImportUpload = multer({
@ -1215,40 +1211,6 @@ router.post('/selection-fission-ai-settings/image-generation/test', async (_req,
// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ====================
router.get('/product-tag-config', async (_req, res) => {
try {
res.json({ success: true, data: await getProductTagConfig() });
} catch (error) {
console.error('获取商品标签配置失败:', error);
res.status(error.statusCode || error.status || 500).json({
success: false,
error: error.message || '获取商品标签配置失败',
});
}
});
router.put('/product-tag-config', async (req, res) => {
try {
const result = await saveProductTagConfig(req.body || {});
const config = await getProductTagConfig();
res.json({
success: true,
message: '商品标签配置已保存到系统数据库',
data: { ...config, saved: result },
});
} catch (error) {
console.error('保存商品标签配置失败:', error);
// 校验类错误回 400,方便前端直接展示原因
const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500);
res.status(status).json({
success: false,
error: error.message || '保存商品标签配置失败',
});
}
});
// ==================== 1688 专用以图搜图 ====================
@ -1517,6 +1479,13 @@ function getDefaultPortByDriver(driver) {
return 3306;
}
function normalizeDatabaseDriver(driver) {
const normalized = String(driver || '').trim().toLowerCase();
if (normalized === 'postgresql' || normalized === 'postgres') return 'postgres';
if (normalized === 'mssql') return 'mssql';
return 'mysql';
}
async function getStoredDatabaseConfig(configName) {
const normalizedName = normalizeDatabaseConfigName(configName);
let configs = await database.local.query(
@ -1655,7 +1624,9 @@ router.put('/database', async (req, res) => {
}
// 根据数据库类型设置默认端口
const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql');
const resolvedDriver = normalizeDatabaseDriver(
driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql')
);
const defaultPort = getDefaultPortByDriver(resolvedDriver);
const configData = {
@ -1749,6 +1720,8 @@ router.post('/database/test', async (req, res) => {
return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' });
}
driver = normalizeDatabaseDriver(driver);
if (driver === 'mssql') {
// SQL Server 连接测试
const sql = require('mssql');
@ -1768,6 +1741,26 @@ router.post('/database/test', async (req, res) => {
const pool = await sql.connect(config);
await pool.request().query('SELECT 1 as test');
await pool.close();
} else if (driver === 'postgres') {
const { Client } = require('pg');
const client = new Client({
host,
port: port || 5432,
database: dbName,
user: username,
password,
ssl: ssl ? { rejectUnauthorized: false } : false,
connectionTimeoutMillis: 10000,
query_timeout: 10000,
statement_timeout: 10000,
});
try {
await client.connect();
await client.query('SELECT 1 AS test');
} finally {
await client.end().catch(() => {});
}
} else {
// MySQL 连接测试
const mysql = require('mysql2/promise');

View File

@ -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;

127
backend/src/api/mkhBom.js Normal file
View File

@ -0,0 +1,127 @@
'use strict';
const express = require('express');
const {
getMkhBomList,
getMkhBomDetail,
listMkhBomExportRows,
clearMkhBomCache,
} = require('../services/mkhBomService');
const {
getMkhReplenishAlertAdminConfig,
resetMkhReplenishAlertState,
saveMkhReplenishAlertConfig,
} = require('../services/mkhReplenishAlertSettings');
const { checkMkhReplenishAlerts } = require('../jobs/mkhReplenishAlertJob');
const {
saveYydNotificationCenterConfig,
} = require('../services/yydNotificationCenterSettings');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[MkhBom]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
function buildSyncQuery(query = {}) {
return { ...query, live: '1', fresh: '1' };
}
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
router.get('/list', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 资料列表失败');
}
});
// 单个 MKH 组合商品的 BOM 配件明细(打印用)
router.get('/detail', async (req, res) => {
try {
const skuId = req.query?.skuId || req.query?.comboSkuId;
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: await getMkhBomDetail(skuId) });
} catch (error) {
sendError(res, error, '加载 MKH-BOM 明细失败');
}
});
// 导出:当前筛选条件下的全部数据(不分页)
// mode=products(默认)→ 商品清单;mode=components → 配件明细摊平
router.get('/export', async (req, res) => {
try {
res.json({ success: true, data: await listMkhBomExportRows(req.query || {}) });
} catch (error) {
sendError(res, error, '导出 MKH-BOM 资料失败');
}
});
// 补货预警推送设置(接收人 / 开关 / 推送范围)
router.get('/alert-config', async (req, res) => {
try {
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
} catch (error) {
sendError(res, error, '加载补货预警推送设置失败');
}
});
// 保存补货预警推送设置
router.put('/alert-config', async (req, res) => {
try {
const data = await saveMkhReplenishAlertConfig(req.body || {});
res.json({ success: true, data });
} catch (error) {
sendError(res, error, '保存补货预警推送设置失败');
}
});
// 立即检查并推送(dryRun: 只预览不发送;force: 忽略「只推新增」推全量;reset: 先清空已推送记录)
router.post('/alert-check', async (req, res) => {
try {
const body = req.body || {};
if (body.reset === true) await resetMkhReplenishAlertState();
const result = await checkMkhReplenishAlerts({
dryRun: body.dryRun === true,
force: body.force === true,
});
res.json({ success: true, data: result });
} catch (error) {
sendError(res, error, '执行补货预警推送失败');
}
});
// 保存统一通知中心凭据(补货预警的推送通道)
router.put('/notification-center', async (req, res) => {
try {
await saveYydNotificationCenterConfig(req.body || {});
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
} catch (error) {
sendError(res, error, '保存统一通知中心配置失败');
}
});
// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。
router.post('/sync', async (req, res) => {
try {
const cache = clearMkhBomCache();
// fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。
// 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。
const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query));
res.set('Cache-Control', 'no-store');
res.json({ success: true, data: { cache, snapshot } });
} catch (error) {
sendError(res, error, '同步 MKH-BOM 数据失败');
}
});
module.exports = router;
module.exports._private = { buildSyncQuery };

View File

@ -0,0 +1,59 @@
'use strict';
const express = require('express');
const {
getModularStockList,
getModularStockFilters,
getPurchaseInTransitList,
syncModularStock,
} = require('../services/modularStockMonitorService');
const router = express.Router();
function sendError(res, error, fallback) {
const status = Number(error?.statusCode) || 500;
console.error('[ModularStockMonitor]', {
message: error?.message || fallback,
code: error?.code || null,
status,
});
res.status(status).json({ success: false, error: error.message || fallback });
}
// 配件库存监控明细列表(默认仅「简尚-崔桥工厂半成品包装项目仓」)
router.get('/list', async (req, res) => {
try {
res.json({ success: true, data: await getModularStockList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载配件库存监控列表失败');
}
});
// 筛选下拉选项(品类 / 预警等级)
router.get('/filters', async (_req, res) => {
try {
res.json({ success: true, data: await getModularStockFilters() });
} catch (error) {
sendError(res, error, '加载配件库存监控筛选选项失败');
}
});
// 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细
router.get('/purchase-in-transit', async (req, res) => {
try {
res.json({ success: true, data: await getPurchaseInTransitList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载采购在途明细失败');
}
});
// 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照
router.post('/sync', async (_req, res) => {
try {
res.json({ success: true, data: await syncModularStock() });
} catch (error) {
sendError(res, error, '同步配件库存监控数据失败');
}
});
module.exports = router;

View File

@ -19,7 +19,8 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app';
let pool = null;
let activeCloudConfig = null;
let cachedCloudConfigCandidates = null;
let lastConnectFailure = null;
// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。
let cachedCloudConfigExpiresAt = 0;
function isPoolUsable(activePool) {
return Boolean(activePool && activePool.connected && activePool.healthy !== false);
@ -31,15 +32,15 @@ const parsePositiveInt = (value, fallback) => {
};
const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000);
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000);
// 原先默认 600000ms(10 分钟),配合 maxAttempts=2 最坏可挂 20 分钟,用户体感就是「卡死」。
// 收敛到 60s:本页最重的冷查实测 16~42s,仍有充足余量。
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 60000);
// 解析「系统库配置」失败时的负缓存存活时间。到期后自动重试,使隧道恢复后无需重启后端。
const NEGATIVE_CONFIG_CACHE_TTL_MS = parsePositiveInt(process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS, 30000);
const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10);
const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000);
const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
const LOCAL_CONFIG_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_CONFIG_DB_CONNECTION_LIMIT, 2);
// 连接建立失败后的冷静期(毫秒)。
// 若业务库被防火墙「丢包」(而非拒绝),每次连接都要死等到 connectTimeout 才会失败;
// 短时间内重复请求没必要把整套失败流程重跑一遍。
const CONNECT_FAILURE_COOLDOWN_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_FAILURE_COOLDOWN_MS, 15000);
const DEFAULT_ENV_KEYS = Object.freeze({
driver: ['CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'],
@ -678,7 +679,15 @@ async function loadCandidateConfig(localPool, profile) {
}
async function resolveCloudConfigCandidates() {
if (cachedCloudConfigCandidates) {
// 缓存有效判据:**非 null** 且在有效期内。
//
// 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库
// 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试,
// 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。
//
// 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL,
// 到期自动重新解析,隧道恢复后无需人工干预即可自愈。
if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) {
return cachedCloudConfigCandidates;
}
@ -694,14 +703,13 @@ async function resolveCloudConfigCandidates() {
if (!candidate?.mssqlConfig) {
continue;
}
// 按真实连接目标去重:public / local 两个标签经常指向同一个 host:port,
// 若按 selectedProfile 去重则视为两个候选,一次请求会把同一个不可达地址串行连接两次。
const dedupeKey = [
candidate.runtimeInfo.selectedProfile,
candidate.runtimeInfo.source,
candidate.runtimeInfo.host,
candidate.runtimeInfo.port,
candidate.runtimeInfo.database,
candidate.runtimeInfo.username,
candidate.mssqlConfig?.password || '',
].join('::');
if (seen.has(dedupeKey)) {
continue;
@ -711,6 +719,7 @@ async function resolveCloudConfigCandidates() {
}
cachedCloudConfigCandidates = candidates;
cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY;
return cachedCloudConfigCandidates;
} catch (error) {
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
@ -722,15 +731,23 @@ async function resolveCloudConfigCandidates() {
}
}
cachedCloudConfigCandidates = [];
// 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。
const fallbacks = [];
if (hasCompleteEnvConfig(process.env, 'default')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
}
if (hasCompleteEnvConfig(process.env, 'public')) {
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)');
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
}
cachedCloudConfigCandidates = fallbacks;
cachedCloudConfigExpiresAt = Date.now() + NEGATIVE_CONFIG_CACHE_TTL_MS;
console.warn(
`[CloudDB] 系统库配置暂不可用(${fallbacks.length > 0 ? '暂用环境变量兜底' : '且无环境变量兜底'}),`
+ `${NEGATIVE_CONFIG_CACHE_TTL_MS}ms 后自动重试`
);
return cachedCloudConfigCandidates;
}
@ -931,20 +948,11 @@ async function createPinnedQueryClient(options = {}) {
/**
* 获取连接池
*/
const getPool = async (options = {}) => {
const getPool = async () => {
if (isPoolUsable(pool)) {
return pool;
}
if (
lastConnectFailure
&& !options.bypassConnectFailureCache
&& Date.now() - lastConnectFailure.at < CONNECT_FAILURE_COOLDOWN_MS
) {
// 冷静期内直接复用上次的失败结果,避免重复等待连接超时
throw lastConnectFailure.error;
}
if (pool) {
console.warn('[CloudDB] 检测到失效连接池,准备重建连接');
await resetPool();
@ -963,33 +971,34 @@ const getPool = async (options = {}) => {
+ `${candidate.runtimeInfo.host}:${candidate.runtimeInfo.port}/${candidate.runtimeInfo.database}`
);
await connectWithCandidate(candidate);
lastConnectFailure = null;
console.log('[CloudDB] MSSQL 云数据库连接成功');
return pool;
} catch (error) {
// 附带候选配置信息,便于上层把报错翻译成「连不上哪台库」的可读提示
error.cloudDbRuntime = candidate.runtimeInfo;
errors.push(error);
console.warn(`[CloudDB] ${candidate.runtimeInfo.selectedProfile} 配置连接失败: ${error.message}`);
}
}
const lastError = errors[errors.length - 1];
if (lastError) {
// 标记为「连接建立失败」:这类失败重试没有意义,且要计入冷静期
lastError.cloudDbConnectFailure = true;
lastConnectFailure = { at: Date.now(), error: lastError };
}
console.error('[CloudDB] MSSQL 连接失败:', lastError?.message || NOT_CONFIGURED_MESSAGE);
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
};
const resetPool = async () => {
lastConnectFailure = null;
/**
* 统一失效「云配置候选」缓存。
*
* 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留,
* 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。
*/
function invalidateCloudConfigCache() {
cachedCloudConfigCandidates = null;
cachedCloudConfigExpiresAt = 0;
}
const resetPool = async () => {
if (!pool) {
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
return;
}
@ -1000,7 +1009,7 @@ const resetPool = async () => {
} finally {
pool = null;
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
invalidateCloudConfigCache();
}
};
@ -1014,7 +1023,7 @@ const query = async (sqlText, params = {}, options = {}) => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = await getPool(options);
const activePool = await getPool();
const resolvedSqlText = rewriteReportReplicaSql(sqlText, activeCloudConfig?.runtimeInfo);
const request = activePool.request();
const requestTimeout = parsePositiveInt(options.requestTimeout, 0);
@ -1032,10 +1041,7 @@ const query = async (sqlText, params = {}, options = {}) => {
const result = await request.query(resolvedSqlText);
return result.recordset;
} catch (error) {
// 连接建立失败不重试:地址不可达时重试只是把同样的超时再跑一遍
const shouldRetry = attempt < maxAttempts
&& !error.cloudDbConnectFailure
&& isTransientError(error);
const shouldRetry = attempt < maxAttempts && isTransientError(error);
if (!shouldRetry) {
throw error;
}
@ -1054,6 +1060,14 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
// 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。
//
// 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。
// TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 ——
// 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。
//
// 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复;
// 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。
throw new Error(NOT_CONFIGURED_MESSAGE);
}
@ -1080,6 +1094,9 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
*/
const test = async () => {
try {
// 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置:
// 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。
invalidateCloudConfigCache();
const candidates = await resolveCloudConfigCandidates();
if (!Array.isArray(candidates) || candidates.length === 0) {
return { success: false, message: NOT_CONFIGURED_MESSAGE };
@ -1108,8 +1125,7 @@ const close = async () => {
}
pool = null;
activeCloudConfig = null;
cachedCloudConfigCandidates = null;
lastConnectFailure = null;
invalidateCloudConfigCache();
};
/**

View File

@ -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,
};

View File

@ -0,0 +1,169 @@
/**
* 产品资料系统数据库连接模块(PostgreSQL)
*
* 数据来源:系统库 database_configs 表中 name = 'product_information_database' 的配置。
* 典型配置:postgres / 218.93.9.38:25432 / furniture3d_prod / external_full_reader / ssl=1
*
* 该库用于 MKH-BOM 资料页面的商品资料匹配:
* external.intake_sku_items_ready_v1
* —— 真实可用的 SKU 明细视图(约 4.6 万行)
* —— source_sku_id 为聚水潭原始 SKU 编码(即业务库 BOM 的配件编码)
* —— sku_code 为商品编码,barcode 为条码
* external.warehouse_locations_v1 —— 库位主数据(当前仅 1 行:A-01 成品区)
*
* 说明:external.product_bom_v1 / product_skus_v1 当前为空视图,不可用。
*/
const { Pool } = require('pg');
const database = require('./database');
const CONFIG_NAME = 'product_information_database';
const READY_VIEW = 'external.intake_sku_items_ready_v1';
const LOCATION_VIEW = 'external.warehouse_locations_v1';
const parsePositiveInt = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const POOL_MAX = parsePositiveInt(process.env.PRODUCT_DB_POOL_MAX, 4);
let pool = null;
let cachedSignature = '';
const truthy = (value) => (
value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1'
);
async function loadStoredConfig() {
const rows = await database.local.query(
`SELECT id, host, port, database_name, username, password, \`ssl\`, driver, status
FROM database_configs WHERE name = ? LIMIT 1`,
[CONFIG_NAME]
);
const config = rows[0];
if (!config) {
const error = new Error('产品资料系统数据库尚未配置,请在「系统设置 - 数据库配置」中完成配置');
error.statusCode = 503;
error.code = 'PRODUCT_DB_NOT_CONFIGURED';
throw error;
}
if (String(config.driver || '').toLowerCase() !== 'postgres') {
const error = new Error('产品资料系统数据库需要选择 PostgreSQL 类型');
error.statusCode = 503;
error.code = 'PRODUCT_DB_DRIVER_MISMATCH';
throw error;
}
return {
id: config.id,
host: String(config.host || '').trim(),
port: Number(config.port) || 5432,
database: String(config.database_name || '').trim(),
user: String(config.username || '').trim(),
password: config.password || '',
ssl: truthy(config.ssl),
};
}
const buildSignature = (config) => [
config.id,
config.host,
config.port,
config.database,
config.user,
config.ssl ? 'ssl' : 'nossl',
].join('::');
async function getPool() {
const config = await loadStoredConfig();
const signature = buildSignature(config);
if (pool && signature === cachedSignature) {
return pool;
}
if (pool) {
await pool.end().catch(() => {});
pool = null;
}
pool = new Pool({
host: config.host,
port: config.port,
database: config.database,
user: config.user,
password: config.password,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
max: POOL_MAX,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 20000,
query_timeout: 180000,
statement_timeout: 180000,
});
pool.on('error', (error) => {
console.warn('[ProductDB] 连接池异常:', error.message);
});
cachedSignature = signature;
return pool;
}
/**
* 执行查询,返回行数组。
* @param {string} sqlText PostgreSQL 语句(使用 $1/$2 占位)
* @param {Array} params 参数数组
*/
async function query(sqlText, params = []) {
const maxAttempts = 2;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const activePool = await getPool();
const result = await activePool.query(sqlText, params);
return result.rows;
} catch (error) {
lastError = error;
if (error.code === 'PRODUCT_DB_NOT_CONFIGURED' || error.code === 'PRODUCT_DB_DRIVER_MISMATCH') {
throw error;
}
if (attempt < maxAttempts) {
console.warn(`[ProductDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`);
await reset();
}
}
}
throw lastError;
}
async function reset() {
if (pool) {
await pool.end().catch(() => {});
}
pool = null;
cachedSignature = '';
}
async function test() {
try {
const rows = await query('SELECT 1 AS test');
return { success: true, message: '产品资料系统数据库连接成功', data: rows };
} catch (error) {
return { success: false, message: `连接失败: ${error.message}` };
}
}
module.exports = {
query,
test,
reset,
getPool,
CONFIG_NAME,
READY_VIEW,
LOCATION_VIEW,
};

View File

@ -29,6 +29,16 @@ const SIDEBAR_PAGES = [
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
buildPlanningPage({
pageKey: 'modular_stock_monitor',
pageName: '模块化库存监控 · 配件库存监控',
path: '/planning/operation/modular-stock-monitor',
}),
buildPlanningPage({
pageKey: 'mkh_bom',
pageName: '模块化库存监控 · MKH-BOM资料',
path: '/planning/operation/mkh-bom-data',
}),
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
{
pageKey: 'settings',

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,396 @@
'use strict';
// MKH 成品补货预警 · 定时推送
//
// 业务口径(2026-09-17 业务确认):
// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货)
// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品
// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示)
// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪)
// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件,
// 不给出建议补货量(否则业务会照着补不到的目标去采购)
// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送,
// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒
// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人)
// ④ 时间:每天早上 09:00(Asia/Shanghai)
//
// 被未完成加工单豁免的「已排产」商品**不进入**推送清单(口径见 mkhBomService 文件头 ⑧)。
const crypto = require('crypto');
const cron = require('node-cron');
const {
listReplenishAlertRows,
} = require('../services/mkhBomService');
const {
getMkhReplenishAlertRuntimeConfig,
loadMkhReplenishAlertState,
resolveMkhReplenishAlertRecipient,
saveMkhReplenishAlertState,
} = require('../services/mkhReplenishAlertSettings');
const {
isYydNotificationCenterConfigured,
sendYydNotification,
} = require('../services/yydNotificationCenterClient');
const {
getYydNotificationCenterRuntimeConfig,
} = require('../services/yydNotificationCenterSettings');
const DEFAULT_CRON = '0 9 * * *';
const TIMEZONE = 'Asia/Shanghai';
const OUT_OF_STOCK = '缺货';
const WARNING = '预警';
// 正文最多列出多少个商品(超出只报数量,避免超出通知中心 4800 字上限)
const MAX_LISTED_ITEMS = 20;
const STARTUP_DELAY_MS = 45 * 1000;
function formatQty(value) {
if (value === null || value === undefined || value === '') return '—';
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(2)));
}
function formatChinaTime(value) {
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return '—';
return new Intl.DateTimeFormat('zh-CN', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
/** 北京时间 YYYYMMDD —— 幂等键按「天 + 本批商品」去重 */
function chinaDateKey(date = new Date()) {
const text = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date instanceof Date ? date : new Date(date));
return text.replace(/\D/g, '');
}
/**
* 对比「当前预警清单」与「已推送记录」:
* fresh 本次新出现的(需要推送)
* persisted 之前推过、目前仍在预警的(不重复推)
* restored 之前推过、现在已经不预警的(补货完成,从记录里剔除)
*/
function diffAlertState(alerts = [], state = {}) {
const notified = state?.notified && typeof state.notified === 'object' ? state.notified : {};
const fresh = [];
const persisted = [];
alerts.forEach((row) => {
const skuId = String(row?.skuId || '');
if (!skuId) return;
if (notified[skuId]) persisted.push(row);
else fresh.push(row);
});
const alertIds = new Set(alerts.map((row) => String(row?.skuId || '')).filter(Boolean));
const restored = Object.keys(notified).filter((skuId) => !alertIds.has(skuId));
return { fresh, persisted, restored };
}
/**
* 生成下一版「已推送」记录:只保留当前仍在预警的商品。
* touch=false 时不刷新 lastNotifiedAt(用于「没有新预警、本轮未真正推送」的场景)。
*/
function buildNotifiedSnapshot(alerts = [], state = {}, nowIso, options = {}) {
const touch = options.touch !== false;
const prev = state?.notified && typeof state.notified === 'object' ? state.notified : {};
const next = {};
alerts.forEach((row) => {
const skuId = String(row?.skuId || '');
if (!skuId) return;
const old = prev[skuId];
const firstNotifiedAt = old?.firstNotifiedAt || nowIso;
next[skuId] = {
level: row.warningLevel || null,
firstNotifiedAt,
lastNotifiedAt: touch ? nowIso : (old?.lastNotifiedAt || firstNotifiedAt),
dailySale: Number(row.dailySale) || 0,
stock: Number(row.stock) || 0,
};
});
return next;
}
/** 本批商品的指纹 —— 同一天内同一批商品只推一次(服务重启后重复触发也能被通知中心去重) */
function buildAlertSignature(alerts = []) {
const parts = alerts
.map((row) => `${String(row?.skuId || '')}:${row?.warningLevel || ''}`)
.sort();
return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16);
}
/**
* 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件,
* 即真正卡住组装的那些,最多 2 个,避免正文过长。
*/
function formatShortageNote(row) {
const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : [];
const detail = bottlenecks
.filter((item) => Number(item?.available) <= 0)
.slice(0, 2)
.map((item) => {
const occupied = Number(item.occupied) || 0;
return `${item.code}(库存 ${formatQty(item.stock)}`
+ `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`;
})
.join('、');
return detail ? `,短板配件:${detail}` : '';
}
function describeAlert(row) {
// 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来,
// 建议补货量不再给数字(显示「—」),与页面口径保持一致。
const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`;
// 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩):
// 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量
const metric = row.orderDriven
? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)`
: `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`;
const lines = [
`**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``,
String(row.productName || '').trim() || '(无名称)',
metric,
];
if (row.componentShortage) {
lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`);
}
return lines.join('\n');
}
function buildMkhReplenishNotification({
alerts = [],
allAlerts = alerts,
snapshot = {},
recipientAlias,
dateKey,
}) {
const listed = alerts.slice(0, MAX_LISTED_ITEMS);
const outOfStockCount = alerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
const totalCount = allAlerts.length || alerts.length;
const totalOutOfStock = Number.isFinite(Number(snapshot.outOfStockCount))
? Number(snapshot.outOfStockCount)
: allAlerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
const totalWarningOnly = Number.isFinite(Number(snapshot.warningOnlyCount))
? Number(snapshot.warningOnlyCount)
: Math.max(0, totalCount - totalOutOfStock);
const titleParts = [`MKH 成品补货预警 · 新增 ${alerts.length} 个`];
if (outOfStockCount > 0) titleParts.push(`含 ${outOfStockCount} 个已缺货`);
const blocks = [
`**仓库:** 简尚-崔桥工厂品牌仓(MKH 模块化成品)`,
`**监控日:** ${snapshot.monitorDate || '—'} | 日销口径:近 ${snapshot.saleWindowDays || 7} 日 | 补货阈值:可用天数低于 ${snapshot.replenishDaysThreshold || 3} 天`,
`**本次新增:** ${alerts.length} 个${listed.length < alerts.length ? `(仅列出前 ${listed.length} 个)` : ''}`,
];
listed.forEach((row, index) => {
blocks.push(`**${index + 1}.** ${describeAlert(row)}`);
});
blocks.push(
[
`---`,
`当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`,
`建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`,
Number(snapshot.orderDrivenCount) > 0
? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。`
: '',
Number(snapshot.componentShortageCount) > 0
? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。`
: '',
snapshot.workOrderDegraded === true
? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。`
: '',
snapshot.pendingOrderDegraded === true
? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。`
: '',
snapshot.componentStockDegraded === true
? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。`
: '',
].filter(Boolean).join('\n\n')
);
const actionUrl = String(
process.env.MKH_REPLENISH_ALERT_ACTION_URL
|| 'https://pddg.jiansuntuzhi.cn/planning/operation/mkh-bom-data?scope=warning'
).trim();
return {
idempotencyKey: `mkh_replenish_alert:${recipientAlias}:${dateKey}:${buildAlertSignature(alerts)}:v1`,
recipientAlias,
title: titleParts.join(' · ').slice(0, 100),
content: blocks.join('\n\n').slice(0, 4800),
actionTitle: '查看补货清单',
actionUrl,
};
}
/**
* 执行一次检查。可选参数:
* force 忽略「只推新增」,把当前全部预警商品推一次(手动触发 / 测试用)
* dryRun 只计算不发送,用于预览将推送的内容
*/
async function checkMkhReplenishAlerts(options = {}) {
const now = options.now === undefined ? new Date() : new Date(options.now);
const nowIso = now.toISOString();
const dryRun = options.dryRun === true;
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const snapshot = await listReplenishAlertRows({ scope: config.scope });
const alerts = snapshot.rows || [];
const state = await loadMkhReplenishAlertState();
const { fresh, persisted, restored } = diffAlertState(alerts, state);
const summary = {
scope: snapshot.scope,
totalCount: snapshot.totalCount,
outOfStockCount: snapshot.outOfStockCount,
warningOnlyCount: snapshot.warningOnlyCount,
replenishQtyTotal: snapshot.replenishQtyTotal,
// 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩
orderDrivenCount: snapshot.orderDrivenCount,
pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal,
// 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪
componentShortageCount: snapshot.componentShortageCount,
freshCount: fresh.length,
persistedCount: persisted.length,
restoredCount: restored.length,
monitorDate: snapshot.monitorDate,
workOrderDegraded: snapshot.workOrderDegraded,
pendingOrderDegraded: snapshot.pendingOrderDegraded,
componentStockDegraded: snapshot.componentStockDegraded,
};
if (!config.enabled) return { sent: false, reason: 'disabled', summary };
// 预警清空(都补完了)→ 清空已推送记录,下次再预警重新提醒
if (!alerts.length) {
if (!dryRun) await saveMkhReplenishAlertState({ notified: {}, lastRunAt: nowIso });
return { sent: false, reason: 'no_alert', summary };
}
const targets = options.force === true ? alerts : fresh;
if (!targets.length) {
if (!dryRun) {
await saveMkhReplenishAlertState({
notified: buildNotifiedSnapshot(alerts, state, nowIso, { touch: false }),
lastRunAt: nowIso,
});
}
return { sent: false, reason: 'no_new_alert', summary };
}
const recipient = await resolveMkhReplenishAlertRecipient({ config });
if (!recipient.alias) return { sent: false, reason: 'recipient_not_configured', summary };
const payload = buildMkhReplenishNotification({
alerts: targets,
allAlerts: alerts,
snapshot,
recipientAlias: recipient.alias,
dateKey: chinaDateKey(now),
});
// 预览不受「通知中心是否配置」影响,方便先核对口径
if (dryRun) {
return {
sent: false,
reason: 'dry_run',
summary,
recipient: recipient.alias,
recipientSource: recipient.source,
payload,
};
}
let centerConfig = null;
try {
centerConfig = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
centerConfig = null;
}
if (!isYydNotificationCenterConfigured(centerConfig)) {
return {
sent: false,
reason: 'notification_center_not_configured',
summary,
recipient: recipient.alias,
payload,
};
}
const response = await sendYydNotification(payload);
await saveMkhReplenishAlertState({
notified: buildNotifiedSnapshot(alerts, state, nowIso),
lastRunAt: nowIso,
});
return {
sent: true,
recipient: recipient.alias,
recipientSource: recipient.source,
newCount: targets.length,
deliveryId: response?.id || response?.data?.id || null,
summary,
};
}
async function runMkhReplenishAlertJob(source = 'scheduled', options = {}) {
try {
const result = await checkMkhReplenishAlerts(options);
if (result.sent) {
console.log(`[MkhReplenishAlert] ${source} 已推送 ${result.newCount} 个新增预警 → ${result.recipient}`);
} else {
console.log(`[MkhReplenishAlert] ${source} 跳过推送: ${result.reason}`);
}
return result;
} catch (error) {
console.error(`[MkhReplenishAlert] ${source} 执行失败:`, error.message || error);
return { sent: false, reason: 'error', error: error.message || String(error) };
}
}
function startMkhReplenishAlertScheduler() {
const disabled = String(process.env.DISABLE_MKH_REPLENISH_ALERT || '').trim().toLowerCase() === 'true';
if (disabled) {
console.log('[Scheduler] MKH 补货预警推送已禁用');
return;
}
const expression = String(process.env.MKH_REPLENISH_ALERT_CRON || DEFAULT_CRON).trim();
cron.schedule(expression, () => {
runMkhReplenishAlertJob('daily_schedule').catch(() => {});
}, { timezone: TIMEZONE });
const startupTimer = setTimeout(() => {
runMkhReplenishAlertJob('startup').catch(() => {});
}, STARTUP_DELAY_MS);
if (typeof startupTimer.unref === 'function') startupTimer.unref();
console.log(`[Scheduler] MKH 补货预警推送已注册: ${expression} (${TIMEZONE})`);
}
module.exports = {
DEFAULT_CRON,
MAX_LISTED_ITEMS,
TIMEZONE,
buildAlertSignature,
buildMkhReplenishNotification,
buildNotifiedSnapshot,
checkMkhReplenishAlerts,
chinaDateKey,
diffAlertState,
formatQty,
runMkhReplenishAlertJob,
startMkhReplenishAlertScheduler,
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,227 @@
'use strict';
// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config)
//
// ── 配置键 mkh_replenish_alert_config ─────────────────────────────
// { enabled, recipientAlias, scope }
// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人
// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货)
//
// ── 状态键 mkh_replenish_alert_state ──────────────────────────────
// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt }
// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送;
// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。
const database = require('../config/database');
const {
getYydNotificationCenterRuntimeConfig,
toAdminConfig: toNotificationCenterAdminConfig,
} = require('./yydNotificationCenterSettings');
const CONFIG_KEY = 'mkh_replenish_alert_config';
const STATE_KEY = 'mkh_replenish_alert_state';
const CONFIG_CACHE_TTL_MS = 30 * 1000;
const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' });
const SCOPE_OPTIONS = Object.freeze([
{ value: SCOPES.WARNING, label: '缺货 + 3天内预警' },
{ value: SCOPES.OUT_OF_STOCK, label: '仅缺货' },
]);
let cachedConfig = null;
let cachedAt = 0;
function normalizeText(value, maxLength = 200) {
return String(value ?? '').trim().slice(0, maxLength);
}
function safeJsonParse(value, fallback = {}) {
if (value && typeof value === 'object') return value;
try {
const parsed = JSON.parse(String(value || ''));
return parsed && typeof parsed === 'object' ? parsed : fallback;
} catch (_error) {
return fallback;
}
}
function normalizeScope(value) {
const text = normalizeText(value, 40).toLowerCase();
if (!text) return SCOPES.WARNING;
if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK;
if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING;
return SCOPES.WARNING;
}
function normalizeFlag(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const text = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
if (['0', 'false', 'no', 'off'].includes(text)) return false;
return fallback;
}
async function readSystemConfigValue(key) {
const rows = await database.local.query(
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
[key]
);
return rows?.[0]?.configValue;
}
async function upsertSystemConfigValue(key, value, configType = 'json') {
const payload = typeof value === 'string' ? value : JSON.stringify(value);
const rows = await database.local.query(
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
[key]
);
if (rows?.[0]) {
await database.local.query(
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
[payload, configType, key]
);
} else {
await database.local.insert('system_config', {
config_key: key,
config_value: payload,
config_type: configType,
});
}
return payload;
}
// ── 配置 ────────────────────────────────────────────────────────
async function getMkhReplenishAlertRuntimeConfig({ forceRefresh = false } = {}) {
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
return { ...cachedConfig };
}
let stored = {};
try {
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
} catch (error) {
console.warn('读取补货预警推送配置失败,使用默认值:', error.message);
}
cachedConfig = {
enabled: normalizeFlag(stored.enabled, true),
recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50),
scope: normalizeScope(stored.scope),
recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50),
};
cachedAt = Date.now();
return { ...cachedConfig };
}
/**
* 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。
*/
async function resolveMkhReplenishAlertRecipient(options = {}) {
const config = options.config || await getMkhReplenishAlertRuntimeConfig();
if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' };
if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' };
let center = {};
try {
center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true });
} catch (_error) {
center = {};
}
const alias = normalizeText(center?.recipientAlias, 50);
return { alias, source: alias ? 'notification_center' : 'none' };
}
async function getMkhReplenishAlertAdminConfig() {
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const recipient = await resolveMkhReplenishAlertRecipient({ config });
let center = {};
try {
center = await getYydNotificationCenterRuntimeConfig();
} catch (_error) {
center = {};
}
const centerAdmin = toNotificationCenterAdminConfig(center);
const state = await loadMkhReplenishAlertState();
return {
enabled: config.enabled,
scope: config.scope,
recipientAlias: config.recipientAlias,
effectiveRecipientAlias: recipient.alias || '',
recipientSource: recipient.source,
notificationCenterConfigured: centerAdmin.configured,
notificationCenterBaseUrl: centerAdmin.baseUrl,
notificationCenter: {
configured: centerAdmin.configured,
baseUrl: centerAdmin.baseUrl,
projectCode: centerAdmin.projectCode,
projectSecretConfigured: centerAdmin.projectSecretConfigured,
projectSecretPreview: centerAdmin.projectSecretPreview,
recipientAlias: centerAdmin.recipientAlias,
},
notifiedCount: Object.keys(state.notified || {}).length,
lastRunAt: state.lastRunAt || null,
scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })),
};
}
async function saveMkhReplenishAlertConfig(input = {}) {
const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
const rawAlias = input.recipientAlias ?? input.recipient_alias;
const next = {
enabled: normalizeFlag(input.enabled, current.enabled),
recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50),
scope: normalizeScope(input.scope || current.scope),
};
await upsertSystemConfigValue(CONFIG_KEY, next);
cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback };
cachedAt = Date.now();
return getMkhReplenishAlertAdminConfig();
}
// ── 推送状态 ────────────────────────────────────────────────────
async function loadMkhReplenishAlertState() {
let parsed = {};
try {
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
} catch (error) {
console.warn('读取补货预警推送状态失败,按空状态处理:', error.message);
}
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
? parsed.notified
: {};
return {
version: 1,
notified,
lastRunAt: normalizeText(parsed.lastRunAt, 40) || null,
};
}
async function saveMkhReplenishAlertState(state = {}) {
const value = {
version: 1,
notified: state.notified && typeof state.notified === 'object' ? state.notified : {},
lastRunAt: normalizeText(state.lastRunAt, 40) || null,
};
await upsertSystemConfigValue(STATE_KEY, value);
return value;
}
/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */
async function resetMkhReplenishAlertState() {
return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null });
}
module.exports = {
CONFIG_KEY,
STATE_KEY,
SCOPES,
SCOPE_OPTIONS,
getMkhReplenishAlertAdminConfig,
getMkhReplenishAlertRuntimeConfig,
loadMkhReplenishAlertState,
normalizeFlag,
normalizeScope,
resetMkhReplenishAlertState,
resolveMkhReplenishAlertRecipient,
saveMkhReplenishAlertConfig,
saveMkhReplenishAlertState,
};

View File

@ -0,0 +1,635 @@
'use strict';
// 模块化库存监控 —— 配件库存监控
//
// 需求来源:《模块化库存监控.xlsx》
// 一级功能:模块化库存监控 二级功能:配件库存监控
// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码
//
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
// 目标仓库共 794 个 SKU
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐;
// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
// jsUseDays 库存可用天数
// safetyDays 安全天数(默认 15)
// suggestStock 建议备货量
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
// nearSecondSale 近一日发货量 → 当日出库
//
// ⚠️ 关联键坑(2026-09-12 修正):
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
//
// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废):
// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」
// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后
// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1)
// - 详见 buildLocationSql() 注释
const cloudDatabase = require('../config/cloudDatabase');
const productDb = require('../config/productInformationDatabase');
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
const WAREHOUSE_WMS_CO_ID = 15718196;
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const DEFAULT_SAFETY_DAYS = 15;
// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0)
// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql)
const LOW_STOCK_DAYS_THRESHOLD = 7;
const WARNING_LEVEL = Object.freeze({
OUT_OF_STOCK: '缺货',
WARNING: '预警',
NORMAL: '正常',
});
// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正):
// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中,
// 被某个 MKH 模块化成品引用:
// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU
// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。
// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。
// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。
// ⚠️ 历史口径均已废弃:
// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关;
// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装,
// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。
// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。
const MKH_STATUS = Object.freeze({
CONFIGURED: '已配置MKH',
UNCONFIGURED: '未配置MKH',
});
const SORTABLE_FIELDS = Object.freeze({
skuId: 's.sku_id',
productName: 's.name',
category: 's.category',
realtimeStock: 'realtimeStock',
avgShip15d: 'm.fifteenAvgSale',
stockDays: 'stockDays',
warningLevel: 'warningOrder',
mkhStatus: 'mkhStatus',
location: 'location',
warehouse: 's.other_3',
});
// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。
// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。
// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。
const buildRealtimeStockSql = () => (
`CASE
WHEN inv.qty > 0 THEN inv.qty
WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty
WHEN inv.qty IS NOT NULL THEN inv.qty
ELSE ISNULL(m.jsStock, 0)
END`
);
// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。
// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。
const buildStockDaysSql = () => (
`CASE
WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN
CASE
WHEN ISNULL(m.fifteenAvgSale, 0) > 0
THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2))
ELSE NULL
END
ELSE m.jsUseDays
END`
);
/**
* 「已配置MKH」判定表达式(2026-09-17 晚修正口径):
* 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。
*/
const buildConfiguredExistsSql = () => (
`EXISTS (
SELECT 1 FROM rpt.jst_skuBomItem x
WHERE x.map_outer_sku_id = s.sku_id
AND x.sku_id LIKE N'MKH%'
)`
);
/**
* 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。
* 0 = 缺货 / 1 = 预警 / 2 = 正常
* 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。
* 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。
* 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。
*/
const buildWarningOrderSql = () => (
`CASE
WHEN cfg.configured = 0 THEN 2
WHEN ${buildRealtimeStockSql()} <= 0 THEN 0
WHEN ${buildStockDaysSql()} IS NOT NULL
AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
ELSE 2
END`
);
/**
* 统一的「仓位」表达式(2026-09-18 改源)。
* 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」);
* 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后,
* 目标仓 794 个 SKU **命中 727 个**(09-19 实测;该表小时级增量刷新,命中数会变)。
* ⚠️ 只取 bin,**不 join 仓名**,所以不受该表 wh_id / wms_co_id 关联列问题影响(见
* mkhBomService 文件头 ⑤;那个坑只影响 MKH-BOM 页配件仓位列的仓名文本)。
* · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位)
* · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果)
* 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。
* ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。
*/
const buildLocationSql = () => (
`COALESCE(
ploc.bin,
NULLIF(LTRIM(RTRIM(s.bin)), N''),
NULLIF(LTRIM(RTRIM(s.other_bin)), N'')
)`
);
const buildBaseSql = () => `
FROM dbo.jst_sku s
OUTER APPLY (
SELECT TOP 1 i.qty
FROM dbo.jst_inventoryInfo i
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
ORDER BY i.updateTime DESC
) inv
OUTER APPLY (
SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
m2.date, m2.jsStock, m2.nearSecondSale
FROM dbo.pm_stockMonitor m2
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
ORDER BY m2.date DESC
) m
OUTER APPLY (
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
AND p.delivery_date >= @monitorDate
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
) pin
-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计
-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、
-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。
-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明,
-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。
OUTER APPLY (
SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit
FROM dbo.jst_purchaseItem p
WHERE p.sku_id = s.sku_id
) ptr
OUTER APPLY (
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
FROM dbo.fys_skuCostPriceUpdate c
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
ORDER BY c.createTime DESC
) cost
-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU
-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。
LEFT JOIN (
SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty
FROM (
SELECT
pk.sku_id,
pk.wms_co_id,
NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin,
SUM(ISNULL(pk.qty, 0)) AS binQty,
SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty,
ROW_NUMBER() OVER (
PARTITION BY pk.sku_id, pk.wms_co_id
ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC
) AS rn
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
WHERE pk.wms_co_id = @wmsCoId
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'')
HAVING SUM(ISNULL(pk.qty, 0)) > 0
) ranked
WHERE ranked.rn = 1
) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
OUTER APPLY (
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
) cfg
WHERE s.other_3 = @warehouseName`;
const toNumber = (value) => {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toText = (value) => {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text === '' ? null : text;
};
const WARNING_ORDER = {
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
[WARNING_LEVEL.WARNING]: 1,
[WARNING_LEVEL.NORMAL]: 2,
};
/**
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
*/
async function resolveMonitorDate() {
const rows = await cloudDatabase.query(
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
FROM dbo.pm_stockMonitor
WHERE warehouse = @warehouseName`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 120000 }
);
const raw = rows?.[0]?.monitorDate;
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
}
const buildFilters = (query = {}, monitorDate = null) => {
const conditions = [];
const params = {
wmsCoId: WAREHOUSE_WMS_CO_ID,
warehouseName: WAREHOUSE_NAME,
monitorDate,
};
const keyword = toText(query.keyword);
if (keyword) {
params.keyword = `%${keyword}%`;
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
}
const category = toText(query.category);
if (category) {
params.category = category;
conditions.push('s.category = @category');
}
const location = toText(query.location);
if (location) {
params.location = location;
conditions.push(`${buildLocationSql()} = @location`);
}
const mkhStatus = toText(query.mkhStatus);
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
params.mkhStatus = mkhStatus;
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
}
const warningLevel = toText(query.warningLevel);
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
params.warningOrder = WARNING_ORDER[warningLevel];
// 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH)
conditions.push(`${buildWarningOrderSql()} = @warningOrder`);
}
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
if (['1', 'true', 'yes'].includes(onlyWarning)) {
// 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除)
conditions.push(`${buildWarningOrderSql()} <= 1`);
}
return { conditions, params };
};
/**
* 用「产品资料系统数据库」补齐商品图片。
*
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
* external.product_catalog_wide_base_v1
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
* 实测目标仓 661 条可匹配,其中 481 条带图。
* 查询失败时静默降级,不影响列表主流程。
*/
async function attachProductImages(rows = []) {
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
if (codes.length === 0) {
return rows;
}
const imageMap = new Map();
try {
const imageRows = await productDb.query(
`SELECT barcode, image_url, product_thumbnail_url
FROM external.product_catalog_wide_base_v1
WHERE barcode = ANY($1)`,
[codes]
);
imageRows.forEach((row) => {
const code = toText(row.barcode);
if (!code || imageMap.has(code)) return;
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
if (url) imageMap.set(code, url);
});
} catch (error) {
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
}
return rows.map((row) => ({
...row,
productImageUrl: imageMap.get(toText(row.skuId)) || null,
}));
}
/**
* 「已配置MKH」判定说明(2026-09-17 晚修正):
* 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护
* rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头),
* 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。
*/
const mapRow = (row = {}) => {
const realtimeStock = Number(row.realtimeStock || 0);
const stockDays = toNumber(row.stockDays);
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
// 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则),
// 前端显示与排序口径保持一致,避免前后端各算一份。
const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]);
const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK]
? WARNING_LEVEL.OUT_OF_STOCK
: warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING]
? WARNING_LEVEL.WARNING
: WARNING_LEVEL.NORMAL;
return {
skuId: toText(row.skuId),
productName: toText(row.productName),
category: toText(row.category),
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
warningLevel,
warningOrder,
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
inQtyToday: toNumber(row.inQtyToday) ?? 0,
outQtyToday: toNumber(row.outQtyToday) ?? 0,
realtimeStock,
purchaseTransit: toNumber(row.purchaseTransit) ?? 0,
location: toText(row.location),
avgShip15d: toNumber(row.avgShip15d),
stockDays,
safetyDays,
suggestStock: toNumber(row.suggestStock),
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
};
};
const buildProjection = () => `
s.sku_id AS skuId,
s.name AS productName,
s.category AS category,
${buildRealtimeStockSql()} AS realtimeStock,
ISNULL(ptr.transit, 0) AS purchaseTransit,
ISNULL(pin.inQty, 0) AS inQtyToday,
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
${buildLocationSql()} AS location,
m.fifteenAvgSale AS avgShip15d,
${buildStockDaysSql()} AS stockDays,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestStock,
s.other_3 AS warehouse,
COALESCE(
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
NULLIF(LTRIM(RTRIM(s.pic)), N''),
cost.pictureSkucode
) AS imageUrl,
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
${buildWarningOrderSql()} AS warningOrder,
m.date AS monitorDate`;
async function getModularStockList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const monitorDate = await resolveMonitorDate();
const { conditions, params } = buildFilters(query, monitorDate);
const baseSql = buildBaseSql();
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
const offset = (page - 1) * pageSize;
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
? orderSql
: `${orderSql}, s.sku_id ASC`;
const rows = await cloudDatabase.query(
`SELECT ${buildProjection()}
${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
);
const [totalRow] = await cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const [summaryRow] = await buildSummary(whereSql, params);
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
const enrichedRows = await attachProductImages(rows);
return {
rows: enrichedRows.map(mapRow),
total: Number(totalRow?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
monitorDate: summaryRow?.monitorDate || monitorDate,
summary: {
skuCount: summaryRow?.skuCount || 0,
outOfStockCount: summaryRow?.outOfStockCount || 0,
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
configuredCount: summaryRow?.configuredCount || 0,
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
totalStock: summaryRow?.totalStock || 0,
totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0,
},
};
}
async function buildSummary(whereSql, params) {
const rows = await cloudDatabase.query(
`SELECT
COUNT_BIG(*) AS skuCount,
SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount,
CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock,
CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit,
SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount,
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
MAX(m.date) AS monitorDate
${whereSql}`,
params,
{ requestTimeout: 180000 }
);
const row = rows[0] || {};
return [{
skuCount: Number(row.skuCount || 0),
outOfStockCount: Number(row.outOfStockCount || 0),
totalStock: Number(row.totalStock || 0),
totalPurchaseTransit: Number(row.totalPurchaseTransit || 0),
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
configuredCount: Number(row.configuredCount || 0),
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
}];
}
async function getModularStockFilters() {
// 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可,
// 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。
const rows = await cloudDatabase.query(
`SELECT DISTINCT s.category
FROM dbo.jst_sku s
WHERE s.other_3 = @warehouseName
AND s.category IS NOT NULL
AND LTRIM(RTRIM(s.category)) <> N''
ORDER BY s.category`,
{ warehouseName: WAREHOUSE_NAME },
{ requestTimeout: 60000 }
);
return {
categories: rows.map((row) => toText(row.category)).filter(Boolean),
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
warehouse: WAREHOUSE_NAME,
};
}
/**
* 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。
* 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。
* 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。
*/
async function getPurchaseInTransitList(query = {}) {
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
const keyword = toText(query.keyword);
const params = { warehouseName: WAREHOUSE_NAME };
let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`;
if (keyword) {
params.keyword = `%${keyword}%`;
whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`;
}
const sortField = String(query.sortField || '').trim();
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const orderMap = {
skuId: 'p.sku_id',
deliveryDate: 'p.delivery_date',
transitQty: 'transitQty',
purchaseQty: 'p.qty',
};
const orderCol = orderMap[sortField] || 'transitQty';
const orderClause = sortField === 'skuId'
? `${orderCol} ${sortOrder}`
: `${orderCol} ${sortOrder}, p.sku_id ASC`;
const offset = (page - 1) * pageSize;
const [rows, totalRow] = await Promise.all([
cloudDatabase.query(
`SELECT
p.sku_id AS skuId,
p.name AS productName,
CAST(p.qty AS BIGINT) AS purchaseQty,
CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty,
CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty,
p.delivery_date AS deliveryDate,
CAST(p.po_id AS NVARCHAR(50)) AS poId,
CAST(p.poi_id AS NVARCHAR(50)) AS poiId
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}
ORDER BY ${orderClause}
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
params,
{ requestTimeout: 180000 }
),
cloudDatabase.query(
`SELECT COUNT_BIG(*) AS total
FROM rpt.jst_purchaseItem p
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
WHERE ${whereSql}`,
params,
{ requestTimeout: 180000 }
),
]);
return {
rows: rows.map((row) => ({
skuId: toText(row.skuId),
productName: toText(row.productName),
purchaseQty: Number(row.purchaseQty || 0),
inQty: Number(row.inQty || 0),
transitQty: Number(row.transitQty || 0),
deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null,
poId: toText(row.poId),
poiId: toText(row.poiId),
})),
total: Number(totalRow?.[0]?.total || 0),
page,
pageSize,
warehouse: WAREHOUSE_NAME,
};
}
// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回,
// 前端据此刷新列表到最新快照。
// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。
async function syncModularStock() {
const monitorDate = await resolveMonitorDate();
return {
syncedAt: new Date().toISOString(),
monitorDate,
};
}
module.exports = {
WAREHOUSE_NAME,
WAREHOUSE_WMS_CO_ID,
WARNING_LEVEL,
DEFAULT_SAFETY_DAYS,
getModularStockList,
getModularStockFilters,
getPurchaseInTransitList,
syncModularStock,
};

View File

@ -0,0 +1,126 @@
/**
* 配置负缓存 TTL 回归测试。
*
* 背景(线上真实故障):
* 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来
* (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy),
* 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。
*
* 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。
* 本文件锁定三条语义:
* ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时)
* ② TTL 到期后必须重试
* ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰
*/
const test = require('node:test');
const assert = require('node:assert/strict');
// ── 必须在加载被测模块之前完成环境与替身准备 ─────────────────────────────
// 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置)
for (const key of Object.keys(process.env)) {
if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) {
delete process.env[key];
}
}
// 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉
process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150';
// 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool
const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise');
const realMysqlPromise = require(MYSQL_PROMISE_PATH);
let systemDbDialAttempts = 0;
let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复
const systemConfigRow = {
name: 'cloud_database',
host: '127.0.0.1',
port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库
database_name: 'JSReportReplica',
username: 'probe',
password: 'probe',
ssl: 0,
driver: 'mssql',
use_env_config: 0,
is_default: 1,
};
require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, {
createPool: () => {
systemDbDialAttempts += 1;
return {
query: async () => {
if (systemDbMode === 'down') {
throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), {
code: 'ECONNREFUSED',
});
}
return [[systemConfigRow]];
},
end: async () => {},
};
},
});
const cloudDatabase = require('../src/config/cloudDatabase');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const NOT_CONFIGURED = /业务数据库尚未配置/u;
test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => {
await cloudDatabase.close();
const base = systemDbDialAttempts;
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置');
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析');
await sleep(220);
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
NOT_CONFIGURED
);
assert.equal(
systemDbDialAttempts,
base + 2,
'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键'
);
});
test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => {
await sleep(220); // 越过上一轮的负缓存 TTL
systemDbMode = 'recovered';
const base = systemDbDialAttempts;
await assert.rejects(
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
(error) => {
// 关键断言:不应再是「尚未配置」——说明配置已成功读回,
// 失败只是发生在后面连业务库那一层(这里故意指向空端口)。
assert.doesNotMatch(
error.message,
NOT_CONFIGURED,
'配置读取应已恢复,不该再报「业务数据库尚未配置」'
);
return true;
}
);
assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置');
// 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库
const afterRecovery = systemDbDialAttempts;
await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'));
assert.equal(
systemDbDialAttempts,
afterRecovery,
'成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)'
);
});

View File

@ -0,0 +1,13 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { _private } = require('../src/api/mkhBom');
test('manual MKH sync always forces a live operational refresh', () => {
assert.deepEqual(
_private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }),
{ page: 2, fresh: '1', live: '1' }
);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,194 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildAlertSignature,
buildMkhReplenishNotification,
buildNotifiedSnapshot,
chinaDateKey,
diffAlertState,
formatQty,
} = require('../src/jobs/mkhReplenishAlertJob');
const alert = (skuId, level = '缺货', extra = {}) => ({
skuId,
warningLevel: level,
productName: `商品 ${skuId}`,
dailySale: 1,
stock: 0,
replenishQty: 3,
...extra,
});
test('diffAlertState splits fresh / persisted / restored', () => {
const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } };
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state);
assert.deepEqual(fresh.map((r) => r.skuId), ['C']);
assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']);
// Z 已补货恢复,应从已推送记录里剔除
assert.deepEqual(restored, ['Z']);
});
test('diffAlertState treats empty state as all fresh', () => {
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {});
assert.equal(fresh.length, 2);
assert.equal(persisted.length, 0);
assert.deepEqual(restored, []);
});
test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => {
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } };
const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z');
assert.deepEqual(Object.keys(next).sort(), ['A', 'B']);
assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z');
assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z');
assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z');
});
test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => {
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } };
const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false });
assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z');
});
test('buildAlertSignature is order independent but content sensitive', () => {
const a = alert('A');
const b = alert('B', '预警');
assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a]));
assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a]));
// 等级变化也算变化(缺货 → 预警 应产生新指纹)
assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')]));
});
test('chinaDateKey renders Beijing date regardless of UTC offset', () => {
// 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17
assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917');
assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916');
});
test('formatQty trims trailing zeros and tolerates bad input', () => {
assert.equal(formatQty(3), '3');
assert.equal(formatQty(2.9700000001), '2.97');
assert.equal(formatQty(0.28), '0.28');
assert.equal(formatQty(null), '—');
assert.equal(formatQty('abc'), '—');
});
test('notification carries recipient, idempotency key and shortfall details', () => {
const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })];
const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })];
const snapshot = {
monitorDate: '2026-09-16',
saleWindowDays: 15,
replenishDaysThreshold: 3,
outOfStockCount: 1,
warningOnlyCount: 1,
replenishQtyTotal: 14,
};
const payload = buildMkhReplenishNotification({
alerts: fresh,
allAlerts: all,
snapshot,
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.equal(payload.recipientAlias, '苏适');
assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`);
assert.ok(payload.title.includes('新增 1 个'));
assert.ok(payload.title.includes('含 1 个已缺货'));
assert.ok(payload.content.includes('MKHDNZ0409260700428'));
assert.ok(payload.content.includes('现有库存 0'));
assert.ok(payload.content.includes('建议补货 9'));
// 汇总用全量口径,不是本批新增
assert.ok(payload.content.includes('当前共 **2** 个'));
assert.equal(payload.actionTitle, '查看补货清单');
assert.ok(payload.content.length <= 4800);
});
test('notification truncates long lists but keeps the totals', () => {
const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`));
const payload = buildMkhReplenishNotification({
alerts: many,
allAlerts: many,
snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 },
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('仅列出前 20 个'));
assert.ok(payload.content.includes('当前共 **40** 个'));
assert.equal(payload.content.includes('MKH0039'), false);
assert.ok(payload.content.length <= 4800);
});
test('notification flags degraded work-order source', () => {
const payload = buildMkhReplenishNotification({
alerts: [alert('A')],
allAlerts: [alert('A')],
snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true },
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('加工单数据源暂不可用'));
});
// ── 配件不足(见 mkhBomService 文件头 ⑪)──────────────────────────
test('notification spells out 配件不足 and drops the replenish target', () => {
const shortage = alert('MKHDNZ0409260600843', '缺货', {
dailySale: 0,
stock: 0,
replenishQty: 1,
componentShortage: true,
assemblableQty: 0,
bottleneckComponents: [
{ code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 },
{ code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 },
],
});
const payload = buildMkhReplenishNotification({
alerts: [shortage],
allAlerts: [shortage],
snapshot: {
outOfStockCount: 1,
warningOnlyCount: 0,
replenishQtyTotal: 1,
componentShortageCount: 1,
},
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('配件不足,无法补货'));
// 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购
assert.ok(payload.content.includes('建议补货 —'));
assert.equal(payload.content.includes('建议补货 1'), false);
// 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列
assert.ok(payload.content.includes('DNZ040958N1440V01'));
assert.equal(payload.content.includes('DNZ041106B01V01'), false);
// 汇总段说明本批里有几个配件不足
assert.ok(payload.content.includes('因**配件库存不足**'));
});
test('notification flags degraded component stock source', () => {
const payload = buildMkhReplenishNotification({
alerts: [alert('A')],
allAlerts: [alert('A')],
snapshot: {
outOfStockCount: 1,
warningOnlyCount: 0,
replenishQtyTotal: 3,
componentStockDegraded: true,
},
recipientAlias: '苏适',
dateKey: '20260917',
});
assert.ok(payload.content.includes('配件库存数据源暂不可用'));
assert.ok(payload.content.includes('未做'));
});

View File

@ -6,7 +6,7 @@ const express = require('express');
test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => {
const originalLoad = Module._load;
Module._load = function (id, parent, ...rest) {
if (parent?.filename.endsWith('/api/planningUpload.js')) {
if (/[\\/]api[\\/]planningUpload\.js$/.test(parent?.filename || '')) {
if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) };
if (id.endsWith('/opsBIncentiveBoardService')) return {
getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [],

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
FRONTEND_DIR="$REPO_DIR/frontend"
SOURCE_GIT_FULL_SHA="$(cd "$REPO_DIR" && git rev-parse HEAD)"
SOURCE_GIT_SHA="$(cd "$REPO_DIR" && git rev-parse --short HEAD)"
SOURCE_GIT_REF="$(cd "$REPO_DIR" && git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
SOURCE_GIT_REF="${SOURCE_GIT_REF:-main}"
test -f "$FRONTEND_DIR/package-lock.json" || {
echo 'frontend/package-lock.json 不存在,停止构建' >&2
exit 1
}
docker run --rm \
-e "SOURCE_GIT_SHA=$SOURCE_GIT_SHA" \
-e "SOURCE_GIT_FULL_SHA=$SOURCE_GIT_FULL_SHA" \
-e "SOURCE_GIT_REF=$SOURCE_GIT_REF" \
-v "$FRONTEND_DIR:/host-frontend" \
node:22-bookworm-slim \
sh -lc '
set -eu
mkdir -p /workspace
tar -C /host-frontend --exclude=node_modules --exclude=dist -cf - . | tar -C /workspace -xf -
cd /workspace
npm ci --no-audit --no-fund
npm run build
mkdir -p /host-frontend/dist
find /host-frontend/dist -mindepth 1 -delete
cp -a /workspace/dist/. /host-frontend/dist/
'
node - "$FRONTEND_DIR/dist/__build_version__.json" "$SOURCE_GIT_FULL_SHA" <<'NODE'
const fs = require('node:fs');
const [versionPath, expectedSha] = process.argv.slice(2);
const metadata = JSON.parse(fs.readFileSync(versionPath, 'utf8'));
if (metadata.sourceGitFullSha !== expectedSha) {
throw new Error(`前端构建 SHA 不匹配:${metadata.sourceGitFullSha} != ${expectedSha}`);
}
process.stdout.write(`前端构建已验证:${metadata.version}\n`);
NODE

File diff suppressed because it is too large Load Diff

View File

@ -37,9 +37,6 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
"vite": "7.2.7"
}
}

View File

@ -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 }
);

View File

@ -0,0 +1,58 @@
import request from './request';
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
const DETAIL_TIMEOUT_MS = 3 * 60 * 1000;
const EXPORT_TIMEOUT_MS = 5 * 60 * 1000;
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
export const getMkhBomList = (params = {}) => request.get(
'/api/mkh-bom/list',
{
params,
timeout: LIST_TIMEOUT_MS,
headers: { 'Cache-Control': 'no-cache' },
}
);
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
export const getMkhBomDetail = (skuId) => request.get(
'/api/mkh-bom/detail',
{ params: { skuId }, timeout: DETAIL_TIMEOUT_MS }
);
// 导出:当前筛选条件下的全部数据(不分页)
// mode=products → 商品清单;mode=components → 配件明细摊平
// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时
export const getMkhBomExportRows = (params = {}) => request.get(
'/api/mkh-bom/export',
{ params: { ...params, live: 1 }, timeout: EXPORT_TIMEOUT_MS }
);
// 补货预警推送设置(接收人 / 开关 / 推送范围)
export const getMkhReplenishAlertConfig = () => request.get('/api/mkh-bom/alert-config');
// 保存补货预警推送设置
export const saveMkhReplenishAlertConfig = (payload = {}) => request.put(
'/api/mkh-bom/alert-config',
payload
);
// 立即检查并推送(dryRun 只预览 / force 忽略「只推新增」/ reset 先清空已推送记录)
export const checkMkhReplenishAlert = (payload = {}) => request.post(
'/api/mkh-bom/alert-check',
payload,
{ timeout: LIST_TIMEOUT_MS }
);
// 保存统一通知中心凭据(补货预警的推送通道)
export const saveMkhNotificationCenter = (payload = {}) => request.put(
'/api/mkh-bom/notification-center',
payload
);
// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照
export const syncMkhBom = (query = {}) => request.post(
'/api/mkh-bom/sync',
{ query },
{ timeout: LIST_TIMEOUT_MS }
);

View File

@ -0,0 +1,29 @@
import request from './request';
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
const META_TIMEOUT_MS = 60 * 1000;
// 配件库存监控明细(默认仅「简尚-崔桥工厂半成品包装项目仓」)
export const getModularStockList = (params = {}) => request.get(
'/api/modular-stock-monitor/list',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 筛选下拉选项(品类 / 预警等级)
export const getModularStockFilters = () => request.get(
'/api/modular-stock-monitor/filters',
{ timeout: META_TIMEOUT_MS }
);
// 采购在途明细列表(目标仓全部「采购数量 > 已入库数量」的采购单明细)
export const getPurchaseInTransitList = (params = {}) => request.get(
'/api/modular-stock-monitor/purchase-in-transit',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照
export const syncModularStock = () => request.post(
'/api/modular-stock-monitor/sync',
null,
{ timeout: META_TIMEOUT_MS }
);

View File

@ -1,189 +1,202 @@
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: 'modular-stock-monitoring', label: '模块化库存监控', icon: 'table', routeKey: 'modular-stock-monitoring', visible: true, order: 30 },
],
},
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{
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: 'modular-stock-monitoring', label: '模块化库存监控', icon: 'table', routeKey: 'modular-stock-monitoring', visible: true, order: 30 },
],
},
{ 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))
);

View File

@ -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% → 商品僵尸',
];

View File

@ -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;
}, {});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,177 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd';
import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getDailyInventory } from '../../api/dailyInventory';
import {
DAILY_INVENTORY_COLUMNS,
COLUMN_SOURCE,
FORMULA_DESCRIPTION,
} from '../../constants/dailyInventory.js';
import './style.css';
const { Title, Text } = Typography;
const TipTitle = ({ tip, children }) => (
<Tooltip title={tip}>
<span className="daily-inventory-th-tip">{children}</span>
</Tooltip>
);
const int = (value) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const decimal = (value, digits = 1) => {
if (value === null || value === undefined || value === '') return '—';
return Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
const renderSigned = (value) => {
if (value === null || value === undefined || value === '') return '—';
const n = Number(value);
if (!Number.isFinite(n) || n === 0) return <span className="daily-inventory-cell--zero">0</span>;
return <span className={n < 0 ? 'daily-inventory-cell--out' : 'daily-inventory-cell--in'}>{int(n)}</span>;
};
const DailyInventory = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [data, setData] = useState(null);
const [helpOpen, setHelpOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const res = await getDailyInventory();
setData(res?.data ?? null);
} catch (err) {
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
setData(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const items = useMemo(() => data?.items ?? [], [data]);
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
const isRuntime = col.source === COLUMN_SOURCE.runtime;
return {
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
dataIndex: col.key,
key: col.key,
align: col.key === 'warehouse' ? 'left' : 'right',
width: col.key === 'warehouse' ? 240 : 130,
fixed: col.key === 'warehouse' ? 'left' : undefined,
className: isRuntime ? 'daily-inventory-col--runtime' : undefined,
render: (value, record) => {
if (record?.isTotal) {
// 合计行:周转天数不累加,显示「—」
if (col.key === 'turnoverDays') return '—';
return int(value);
}
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
if (col.key === 'closingQty') return int(value);
if (col.key === 'turnoverDays') return decimal(value, 1);
return renderSigned(value);
},
};
}), []);
const tableRows = useMemo(() => {
if (!data?.totals) return items;
return [...items, { ...data.totals, key: '__total__' }];
}, [items, data]);
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
return (
<SycmPage className="daily-inventory-page">
<div className="daily-inventory-header">
<div className="daily-inventory-header__title">
<Title level={4} style={{ margin: 0 }}>每日进销存</Title>
<Button
type="text"
icon={<InfoCircleOutlined />}
onClick={() => setHelpOpen(true)}
className="daily-inventory-header__help"
>
口径说明
</Button>
</div>
<div className="daily-inventory-header__meta">
<span>基准日期:{data?.baselineDate || '—'}</span>
<span>库存快照日:{data?.latestBizDate || '—'}</span>
<span>分级基准时刻:{data?.asOf || '—'}</span>
{data?.items?.[0]?.closingTakenAt ? (
<span>
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
</span>
) : null}
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
</Space>
</div>
<Alert
type="info"
showIcon
message="数据来源混合,请注意口径"
description={
<span>
期初数量与 12 个流水列取自 <b>{data?.baselineDate || '2026-09-22'}</b> 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原);
期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''}
</span>
}
style={{ marginBottom: 12 }}
/>
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<Table
className="daily-inventory-table"
columns={columns}
dataSource={tableRows}
rowKey={(record) => record.key || record.warehouse}
size="small"
bordered
pagination={false}
scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }}
rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')}
/>
</Spin>
<Text type="secondary" className="daily-inventory-footer">
共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。
</Text>
<Modal
title="计算口径说明"
open={helpOpen}
onCancel={() => setHelpOpen(false)}
footer={null}
width={720}
>
<div className="daily-inventory-help">
{FORMULA_DESCRIPTION.map((line, i) => (
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
))}
</div>
</Modal>
</SycmPage>
);
};
export default DailyInventory;

View File

@ -0,0 +1,91 @@
.daily-inventory-page {
padding: 16px 20px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.daily-inventory-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.daily-inventory-header__title {
display: flex;
align-items: center;
gap: 4px;
}
.daily-inventory-header__help {
font-size: 13px;
}
.daily-inventory-header__meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: rgba(0, 0, 0, 0.55);
}
.daily-inventory-th-tip {
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
cursor: help;
white-space: nowrap;
}
.daily-inventory-table .ant-table-thead > tr > th {
white-space: nowrap;
font-size: 12px;
padding: 6px 8px;
}
.daily-inventory-table .ant-table-tbody > tr > td {
padding: 5px 8px;
font-size: 13px;
}
/* 运行时取数的列:加淡蓝底纹,与基准值区分 */
.daily-inventory-table .daily-inventory-col--runtime {
background: rgba(22, 119, 255, 0.04);
}
.daily-inventory-warehouse {
white-space: nowrap;
}
.daily-inventory-cell--out {
color: #cf1322;
}
.daily-inventory-cell--in {
color: rgba(0, 0, 0, 0.85);
}
.daily-inventory-cell--zero {
color: rgba(0, 0, 0, 0.3);
}
.daily-inventory-row--total > td {
font-weight: 600;
background: rgba(0, 0, 0, 0.03);
}
.daily-inventory-footer {
font-size: 12px;
}
.daily-inventory-help p {
margin: 0 0 6px;
line-height: 1.7;
font-size: 13px;
white-space: pre-wrap;
}
.daily-inventory-help p.is-indent {
padding-left: 16px;
color: rgba(0, 0, 0, 0.65);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,393 @@
.mkh-page {
padding: 16px 20px 24px;
}
.mkh-header {
margin-bottom: 14px;
}
.mkh-header__heading .ant-typography {
margin-bottom: 4px;
}
.mkh-note {
display: block;
max-width: 1180px;
margin-top: 6px;
font-size: 12px;
line-height: 1.7;
}
.mkh-filter-card {
margin-bottom: 12px;
}
.mkh-panel {
margin-top: 4px;
}
.mkh-sku {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
color: var(--sycm-text-primary);
}
.mkh-muted {
color: var(--sycm-text-tertiary);
}
.mkh-detail-loading {
padding: 12px 8px;
color: var(--sycm-text-secondary);
}
.mkh-detail {
padding: 4px 8px 8px;
}
.mkh-detail-tip {
padding: 4px 0 8px;
font-size: 12px;
}
.mkh-detail-code {
display: block;
margin-top: 2px;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
font-size: 11px;
color: var(--sycm-text-tertiary);
}
.mkh-tag {
display: inline-block;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.mkh-tag--ok {
color: #0f6e56;
background: rgba(29, 158, 117, 0.14);
}
.mkh-tag--muted {
color: var(--sycm-text-tertiary);
background: rgba(136, 135, 128, 0.14);
}
.mkh-tag--src {
color: #0958d9;
background: rgba(22, 119, 255, 0.12);
}
/* ============ 补货预警 ============ */
.mkh-alert {
margin-bottom: 12px;
}
/* 缺货 */
.mkh-tag--danger {
color: #a8071a;
background: rgba(255, 77, 79, 0.14);
}
/* 预警 */
.mkh-tag--warn {
color: #ad4e00;
background: rgba(250, 173, 20, 0.2);
}
/* 可用天数低于阈值 */
.mkh-days--warn {
color: #cf1322;
font-weight: 600;
}
/* 建议补货量 */
.mkh-replenish {
color: #0958d9;
font-weight: 600;
}
/* 已排产:半成品包装仓已有未完成加工单,按业务口径不再预警 */
.mkh-tag--scheduled {
color: #0958d9;
background: rgba(22, 119, 255, 0.14);
}
/* 在途加工量(未完成加工单待入库数量) */
.mkh-inbound {
color: #0958d9;
font-weight: 600;
}
/* 待发货订单(聚水潭未发货订单件数,见文件头 ⑩) */
.mkh-order {
color: #ad4e00;
font-weight: 600;
}
/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a):
虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */
.mkh-sale-fallback {
border-bottom: 1px dashed currentColor;
cursor: help;
}
/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */
.mkh-tag--shortage {
color: #722ed1;
background: rgba(114, 46, 209, 0.14);
border-color: rgba(114, 46, 209, 0.35);
}
/* ============ A4 打印预览 ============ */
.mkh-print-mask {
position: fixed;
inset: 0;
z-index: 2000;
overflow: auto;
background: rgba(0, 0, 0, 0.55);
padding: 24px 0 40px;
}
.mkh-print-toolbar {
position: sticky;
top: 0;
z-index: 2;
display: flex;
justify-content: center;
padding: 12px;
margin-bottom: 16px;
}
.mkh-print-sheet {
width: 210mm;
min-height: 297mm;
margin: 0 auto;
padding: 14mm 12mm;
background: #fff;
color: #000;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
box-sizing: border-box;
font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
font-size: 12px;
line-height: 1.5;
}
.mkh-print-head {
text-align: center;
border-bottom: 2px solid #000;
padding-bottom: 10px;
margin-bottom: 12px;
}
.mkh-print-title {
font-size: 22px;
font-weight: 700;
letter-spacing: 2px;
}
.mkh-print-sub {
margin-top: 4px;
font-size: 12px;
color: #444;
}
.mkh-print-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 24px;
margin-bottom: 12px;
font-size: 13px;
}
.mkh-print-meta > div {
display: flex;
}
.mkh-print-meta span {
color: #555;
min-width: 78px;
}
.mkh-print-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.mkh-print-table th,
.mkh-print-table td {
border: 1px solid #000;
padding: 6px 8px;
text-align: left;
word-break: break-all;
}
.mkh-print-table thead th {
background: #f2f2f2;
font-weight: 700;
}
.mkh-print-table .mkh-center {
text-align: center;
}
.mkh-print-table .mkh-muted {
color: #999;
}
.mkh-print-table .mkh-print-sku {
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
/* 2026-09-18 业务要求调大:原来是 11px,车间核对编码时太小 */
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
vertical-align: middle;
}
.mkh-print-code {
display: block;
margin-top: 2px;
font-family: 'SFMono-Regular', Consolas, Menlo, monospace;
/* 2026-09-18 业务要求调大:原来是 10px / #666,扫码与人工核对都偏小偏淡 */
font-size: 12px;
font-weight: 600;
color: #222;
}
.mkh-print-foot {
margin-top: 16px;
padding-top: 8px;
border-top: 1px dashed #999;
font-size: 11px;
color: #666;
}
/* 打印时只输出 A4 纸张,隐藏其余界面 */
@media print {
body * {
visibility: hidden !important;
}
.mkh-print-sheet,
.mkh-print-sheet * {
visibility: visible !important;
}
.mkh-print-toolbar {
display: none !important;
}
.mkh-print-mask {
position: absolute !important;
inset: 0 !important;
background: #fff !important;
overflow: visible !important;
padding: 0 !important;
}
.mkh-print-sheet {
width: 210mm;
min-height: auto;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
}
@page {
size: A4;
margin: 12mm;
}
}
/* ── 补货预警推送设置弹窗 ──────────────────────────── */
.mkh-alert-form {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.mkh-alert-row {
display: flex;
align-items: center;
gap: 10px;
}
.mkh-alert-label {
flex: none;
width: 68px;
color: var(--sycm-text-secondary);
}
.mkh-alert-meta {
margin-top: 14px;
padding: 10px 12px;
border: 1px solid var(--sycm-border-color);
border-radius: 8px;
background: var(--sycm-bg-subtle);
font-size: 12px;
line-height: 2;
color: var(--sycm-text-secondary);
}
.mkh-alert-result {
margin-top: 14px;
}
.mkh-alert-result-body {
font-size: 12px;
line-height: 1.8;
}
.mkh-alert-preview {
margin-top: 8px;
padding: 10px 12px;
border: 1px solid var(--sycm-border-color);
border-radius: 8px;
background: var(--sycm-bg-container);
}
.mkh-alert-preview-title {
margin-bottom: 6px;
font-weight: 600;
color: var(--sycm-text-strong);
}
.mkh-alert-preview pre {
margin: 0 0 6px;
font-family: inherit;
font-size: 12px;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
color: var(--sycm-text-primary);
}
.mkh-alert-hint {
margin-top: 10px;
font-size: 12px;
line-height: 1.7;
color: var(--sycm-text-tertiary);
}
/* 推送通道(统一通知中心)折叠区 */
.mkh-alert-center {
margin-top: 6px;
}
.mkh-alert-center .ant-collapse-header {
padding: 8px 0 !important;
}
.mkh-alert-center .ant-collapse-content-box {
padding: 0 0 4px !important;
}
.mkh-alert-form--inner {
margin-top: 4px;
}

View File

@ -0,0 +1,614 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Col, Dropdown, Image, Input, Modal, Row, Select,
Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' };
// 表头带口径说明,避免把「真实值 0」误读成「没数据」
const TipTitle = ({ title, tip }) => (
<Tooltip title={tip}>
<span className="msm-th-tip">{title}</span>
</Tooltip>
);
const number = (value, digits = 2) => value === null || value === undefined || value === ''
? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
const int = (value) => number(value, 0);
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
const toOptions = (list = []) => (Array.isArray(list) ? list : [])
.filter(Boolean)
.map((value) => ({ value, label: value }));
const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' };
const AccessoryStockPanel = () => {
const [filters, setFilters] = useState(EMPTY_FILTERS);
const [onlyWarning, setOnlyWarning] = useState(false);
const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] });
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [syncing, setSyncing] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' });
const tableWrapRef = useRef(null);
const [bodyHeight, setBodyHeight] = useState(420);
// 采购在途明细弹窗状态
const [transitVisible, setTransitVisible] = useState(false);
const [transitData, setTransitData] = useState({ rows: [], total: 0 });
const [transitLoading, setTransitLoading] = useState(false);
const [transitPage, setTransitPage] = useState(1);
const [transitPageSize, setTransitPageSize] = useState(50);
const [transitKeyword, setTransitKeyword] = useState('');
const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' });
// 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc:
// 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行
useEffect(() => {
const el = tableWrapRef.current;
if (!el) return undefined;
// 分页器 + 卡片内边距 + 横向滚动条余量
const RESERVE_BELOW = 104;
const measure = () => {
const scroller = el.closest('.planning-subsystem-content');
const elRect = el.getBoundingClientRect();
let available;
if (scroller) {
const scrollerRect = scroller.getBoundingClientRect();
// 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响
available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop;
} else {
available = window.innerHeight - elRect.top - RESERVE_BELOW;
}
const next = Math.max(240, Math.round(available));
setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
if (el.parentElement) ro.observe(el.parentElement);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
useEffect(() => {
let cancelled = false;
getModularStockFilters()
.then((result) => {
const payload = result?.data || {};
if (cancelled) return;
setFilterOptions({
categories: payload.categories || [],
warningLevels: payload.warningLevels || ['缺货', '预警', '正常'],
mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'],
});
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const query = useMemo(() => {
const params = {
page,
pageSize,
sortField: sorter.field,
sortOrder: sorter.order,
};
Object.entries(filters).forEach(([key, value]) => {
if (value) params[key] = value;
});
if (onlyWarning) params.onlyWarning = '1';
return params;
}, [page, pageSize, sorter, filters, onlyWarning]);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const result = await getModularStockList(query);
setData(result?.data || { rows: [], total: 0, summary: {} });
} catch (requestError) {
setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败');
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const changeFilter = (key) => (value) => {
setFilters((old) => ({ ...old, [key]: value || '' }));
setPage(1);
};
const resetFilters = () => {
setFilters(EMPTY_FILTERS);
setOnlyWarning(false);
setPage(1);
};
// 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细
const applySummaryFilter = (type) => () => {
const next = { ...EMPTY_FILTERS };
let warning = false;
if (type === 'outOfStock') {
next.warningLevel = '缺货';
warning = true;
} else if (type === 'lowStockDays') {
next.warningLevel = '预警';
warning = true;
} else if (type === 'configured') {
next.mkhStatus = '已配置MKH';
} else if (type === 'unconfigured') {
next.mkhStatus = '未配置MKH';
}
setFilters(next);
setOnlyWarning(warning);
setPage(1);
setTimeout(() => {
tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
};
// 打开采购在途明细弹窗
const openPurchaseTransit = () => {
setTransitVisible(true);
setTransitPage(1);
setTransitKeyword('');
setTransitSorter({ field: 'transitQty', order: 'DESC' });
loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' });
};
const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => {
setTransitLoading(true);
try {
const result = await getPurchaseInTransitList({
page: p,
pageSize: transitPageSize,
keyword: kw,
sortField: sort.field,
sortOrder: sort.order,
});
setTransitData(result?.data || { rows: [], total: 0 });
} catch (err) {
message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败');
} finally {
setTransitLoading(false);
}
}, [transitPage, transitPageSize, transitKeyword, transitSorter]);
const columns = useMemo(() => [
{
title: '图片',
dataIndex: 'imageUrl',
width: 56,
fixed: 'left',
exportable: false,
render: (value) => (value
? <Image src={value} width={36} height={36} style={{ objectFit: 'cover', borderRadius: 4 }} placeholder />
: <span className="msm-muted">--</span>),
},
{
title: '预警',
dataIndex: 'warningLevel',
width: 76,
sorter: true,
render: (value) => (value
? <Tag color={WARNING_COLOR[value] || 'default'}>{value}</Tag>
: <span className="msm-muted">--</span>),
},
{
title: <TipTitle
title="状态"
tip="该商品(配件)是否作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中、被某 MKH 模块化成品引用。已配置MKH = 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id 命中该商品编码、且父项 sku_id 以 MKH 开头(被某 MKH 成品 BOM 引用为配件);未配置MKH = 未出现在任何 MKH 成品 BOM 的配件清单中"
/>,
dataIndex: 'mkhStatus',
width: 110,
sorter: true,
render: (value) => {
const v = value || '未配置MKH';
return v === '已配置MKH'
? <Tag color="green">{v}</Tag>
: <Tag color="default">{v}</Tag>;
},
},
{
title: '商品编码',
dataIndex: 'skuId',
width: 176,
fixed: 'left',
sorter: true,
render: (value) => <span className="msm-sku">{text(value)}</span>,
},
{
// 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因)
title: '商品名称',
dataIndex: 'productName',
width: 220,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: <TipTitle
title="当日入库库存"
tip="监控日(pm_stockMonitor 最新快照日)的采购到货入库数量,取自 jst_purchaseItem.inQty。业务库无出入库流水表,0 表示该 SKU 当日无到货"
/>,
dataIndex: 'inQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="当日出库库存"
tip="监控日的最近一日发货量,取自 pm_stockMonitor.nearSecondSale。业务库无出入库流水表,0 表示该 SKU 当日无发货"
/>,
dataIndex: 'outQtyToday',
width: 116,
align: 'right',
render: (value) => (value === null || value === undefined
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="实时库存"
tip="优先取目标仓 jst_inventoryInfo;该表为 0 或缺少记录、但箱及仓位库存已有正库存时,用同仓 jst_packInventoryInfo 合计补齐"
/>,
dataIndex: 'realtimeStock',
width: 96,
align: 'right',
sorter: true,
render: int,
},
{
title: <TipTitle
title="采购在途数量"
tip="采购单中『已下单 − 已入库』的数量,取自 jst_purchaseItem.qty − inQty;它与实时库存独立,上游采购到货字段滞后时可能同时显示库存和在途"
/>,
dataIndex: 'purchaseTransit',
width: 120,
align: 'right',
render: (value) => (value === null || value === undefined || Number(value) === 0
? <span className="msm-muted">--</span>
: int(value)),
},
{
title: <TipTitle
title="仓位"
tip="取自聚水潭「箱及仓位库存」jst_packInventoryInfo 的真货位(bin),同商品多箱时显示库存最大的主货位;该表查不到才显示「未维护」"
/>,
dataIndex: 'location',
width: 88,
sorter: true,
render: (value) => (value
? <span>{value}</span>
: <Tooltip title="上游未提供库位数据">
<span className="msm-muted">未维护</span>
</Tooltip>),
},
{
title: '近15天日均发货量',
dataIndex: 'avgShip15d',
width: 138,
align: 'right',
sorter: true,
render: (value) => number(value),
},
{
title: '库存可用天数',
dataIndex: 'stockDays',
width: 116,
align: 'right',
sorter: true,
render: (value) => number(value, 1),
},
{
title: '所在仓库',
dataIndex: 'warehouse',
width: 200,
ellipsis: true,
sorter: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
], []);
const exportColumns = useMemo(
() => columns.filter((column) => column.exportable !== false),
[columns]
);
const handleExport = async (format) => {
if (!data.rows.length) {
message.warning('暂无数据可导出');
return;
}
try {
await exportTableFile(
format,
`配件库存监控_${data.monitorDate || '最新'}`,
exportColumns,
data.rows,
'配件库存监控'
);
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
} catch (exportError) {
message.error(exportError?.message || '导出失败');
}
};
// 手动同步:后端重新取最新监控日(清服务端缓存)后再拉一次列表,
// 拿到的是副本库当前最新快照;可随时点击,不受 24 点定时任务限制
const handleSync = async () => {
setSyncing(true);
try {
await syncModularStock();
await load();
message.success('已同步最新数据');
} catch (syncError) {
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
} finally {
setSyncing(false);
}
};
const summary = data.summary || {};
return (
<>
<Card className="msm-filter-card" bordered={false}>
<Space wrap size={[12, 12]}>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="商品编码 / 名称"
value={filters.keyword}
onChange={(e) => changeFilter('keyword')(e.target.value)}
style={{ width: 220 }}
/>
<Select
allowClear
showSearch
placeholder="品类"
options={toOptions(filterOptions.categories)}
value={filters.category || undefined}
onChange={changeFilter('category')}
style={{ width: 200 }}
/>
<Select
allowClear
placeholder="预警等级"
options={toOptions(filterOptions.warningLevels)}
value={filters.warningLevel || undefined}
onChange={changeFilter('warningLevel')}
style={{ width: 140 }}
/>
<Select
allowClear
placeholder="MKH 配置状态"
options={toOptions(filterOptions.mkhStatuses)}
value={filters.mkhStatus || undefined}
onChange={changeFilter('mkhStatus')}
style={{ width: 160 }}
/>
<Space size={6}>
<Switch checked={onlyWarning} onChange={(checked) => { setOnlyWarning(checked); setPage(1); }} size="small" />
<Text type="secondary">仅看缺货 / 预警</Text>
</Space>
<Button onClick={resetFilters}>重置</Button>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
<Tooltip title="立即重新拉取副本库最新数据(清服务端缓存后重查),可随时点击,不必等凌晨的定时刷新">
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
</Tooltip>
</Space>
</Card>
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
<Row gutter={[12, 12]} className="msm-summary-row">
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card"><Statistic title="SKU 总数" value={summary.skuCount || 0} /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-red is-clickable" onClick={applySummaryFilter('outOfStock')}>
<Statistic title="缺货 SKU" value={summary.outOfStockCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-orange is-clickable" onClick={applySummaryFilter('lowStockDays')}>
<Statistic title="可用天数不足" value={summary.lowStockDaysCount || 0} suffix="个 SKU" />
</Card>
</Col>
<Col xs={12} md={4}><Card bordered={false} className="msm-summary-card is-green"><Statistic title="实时库存合计" value={summary.totalStock || 0} /></Card></Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-blue is-clickable" onClick={openPurchaseTransit}>
<Statistic title="采购在途" value={summary.totalPurchaseTransit || 0} suffix="件" />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('configured')}>
<Statistic title="已配置MKH" value={summary.configuredCount || 0} />
</Card>
</Col>
<Col xs={12} md={4}>
<Card bordered={false} className="msm-summary-card is-clickable" onClick={applySummaryFilter('unconfigured')}>
<Statistic title="未配置MKH" value={summary.unconfiguredCount || 0} />
</Card>
</Col>
</Row>
<Card
bordered={false}
className="msm-panel"
title={(
<Space size={12} wrap>
<span>配件库存明细({data.total || 0} 条)</span>
{data.monitorDate ? <Text type="secondary" className="msm-panel__date">监控日 {data.monitorDate}</Text> : null}
</Space>
)}
extra={(
<Dropdown menu={{
items: [
{ key: 'xlsx', label: '导出 XLSX' },
{ key: 'csv', label: '导出 CSV' },
],
onClick: ({ key }) => handleExport(key),
}}>
<Button icon={<DownloadOutlined />}>导出</Button>
</Dropdown>
)}
>
<Spin spinning={loading} tip="正在加载配件库存监控数据…">
<div ref={tableWrapRef}>
<Table
rowKey="skuId"
size="small"
columns={columns}
dataSource={data.rows || []}
onChange={(pagination, _filter, nextSorter) => {
setPage(pagination.current || 1);
setPageSize(pagination.pageSize || 50);
if (nextSorter?.field) {
setSorter({
field: nextSorter.field,
order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC',
});
}
}}
pagination={{
current: data.page || page,
pageSize: data.pageSize || pageSize,
total: data.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1508, y: bodyHeight }}
/>
</div>
</Spin>
</Card>
<Modal
title={(
<Space>
<span>采购在途明细</span>
<Text type="secondary" style={{ fontSize: 13 }}>
共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件
</Text>
</Space>
)}
open={transitVisible}
onCancel={() => setTransitVisible(false)}
width={960}
footer={null}
destroyOnHidden
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Input.Search
allowClear
placeholder="商品编码 / 名称"
defaultValue={transitKeyword}
onSearch={(value) => { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }}
style={{ width: 260 }}
/>
<Table
rowKey={(row) => `${row.skuId}-${row.poiId || row.poId || Math.random()}`}
size="small"
loading={transitLoading}
columns={[
{
title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => <span className="msm-sku">{text(v)}</span>,
},
{
title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => <Tooltip title={v}>{text(v)}</Tooltip>,
},
{ title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true },
{ title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true },
{
title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true,
render: (value) => <strong style={{ color: '#1677ff' }}>{int(value)}</strong>,
},
{
title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true,
render: (value) => text(value),
},
{ title: '采购单号', dataIndex: 'poId', width: 116, render: text },
]}
dataSource={transitData.rows || []}
pagination={{
current: transitPage,
pageSize: transitPageSize,
total: transitData.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
onChange={(pagination, _filters, nextSorter) => {
setTransitPage(pagination.current || 1);
setTransitPageSize(pagination.pageSize || 50);
const nextSort = nextSorter?.field
? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' }
: { field: 'transitQty', order: 'DESC' };
setTransitSorter(nextSort);
loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort);
}}
scroll={{ x: 860, y: 360 }}
/>
</Space>
</Modal>
</>
);
};
const ModularStockMonitor = () => (
<SycmPage className="msm-page">
<header className="msm-header">
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数
</Text>
<Text type="secondary" className="msm-note">
数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、
库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖);
当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准;
仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」;
图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。
注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天);
库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。
状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。
</Text>
</div>
</header>
<Tabs
className="msm-tabs"
activeKey="accessory"
items={[{
key: 'accessory',
label: '配件库存监控',
children: <AccessoryStockPanel />,
}]}
/>
</SycmPage>
);
export default ModularStockMonitor;

View File

@ -0,0 +1,166 @@
.msm-page {
container-name: modular-stock-monitor;
container-type: inline-size;
display: flex;
min-height: calc(100% - 46px);
flex-direction: column;
gap: var(--sycm-page-section-gap);
background: var(--sycm-bg-page-soft);
}
.msm-header {
display: flex;
min-height: 84px;
align-items: center;
justify-content: space-between;
gap: var(--sycm-space-lg);
margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0;
padding: 14px calc(var(--sycm-page-padding-x) + 2px);
border-bottom: 1px solid var(--sycm-border-base);
background: var(--sycm-bg-container);
}
.msm-header__heading { min-width: 0; }
.msm-header h3 {
margin: 0;
color: var(--sycm-text-strong);
font-size: var(--sycm-font-size-page);
font-weight: var(--sycm-font-weight-semibold);
line-height: var(--sycm-line-height-tight);
}
.msm-header__heading .ant-typography {
display: block;
margin-top: 6px;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.msm-tabs .ant-tabs-nav {
margin: 0;
padding: 0 calc(var(--sycm-page-padding-x) + 2px);
border-bottom: 1px solid var(--sycm-border-base);
background: var(--sycm-bg-container);
}
.msm-tabs .ant-tabs-tab {
margin: 0;
padding: 12px 4px;
font-size: var(--sycm-font-size-title);
}
.msm-tabs .ant-tabs-content-holder,
.msm-tabs .ant-tabs-content,
.msm-tabs .ant-tabs-tabpane { min-height: 100%; }
.msm-tabs .ant-tabs-tabpane {
display: flex;
flex-direction: column;
gap: var(--sycm-page-section-gap);
padding-top: var(--sycm-page-section-gap);
}
.msm-filter-card,
.msm-panel,
.msm-summary-card {
border: 1px solid var(--sycm-border-card) !important;
border-radius: var(--sycm-radius-lg) !important;
box-shadow: var(--sycm-shadow-card) !important;
}
.msm-filter-card { margin-bottom: 0; }
.msm-filter-card .ant-card-body { padding: 14px 16px; }
.msm-filter-card .ant-space { width: 100%; }
.msm-filter-card .ant-space-item { max-width: 100%; }
.msm-filter-card .ant-select,
.msm-filter-card .ant-input-affix-wrapper { max-width: 100%; }
.msm-summary-row { margin-top: -4px; }
.msm-summary-row .ant-col { display: flex; }
.msm-summary-card {
position: relative;
width: 100%;
min-height: 98px;
overflow: hidden;
}
.msm-summary-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--sycm-color-primary);
content: '';
}
.msm-summary-card.is-red::before { background: var(--sycm-color-danger); }
.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); }
.msm-summary-card.is-green::before { background: var(--sycm-color-success); }
.msm-summary-card.is-blue::before { background: var(--sycm-color-primary); }
.msm-summary-card .ant-card-body {
display: flex;
min-height: 98px;
align-items: center;
padding: 16px 18px;
}
.msm-summary-card .ant-statistic { width: 100%; }
.msm-summary-card .ant-statistic-title {
margin-bottom: 8px;
color: var(--sycm-text-secondary);
font-size: var(--sycm-font-size-caption);
}
.msm-summary-card .ant-statistic-content {
color: var(--sycm-text-strong);
font-family: var(--sycm-font-family-number);
font-size: var(--sycm-font-size-metric);
line-height: 1.1;
}
.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); }
.msm-summary-card.is-clickable {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.msm-summary-card.is-clickable:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.msm-panel { margin-bottom: 0; overflow: hidden; }
.msm-panel .ant-card-head {
min-height: 48px;
padding: 0 16px;
border-bottom-color: var(--sycm-border-light);
}
.msm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); }
.msm-panel .ant-card-body { padding: 0 16px 16px; }
.msm-panel .ant-table { font-size: var(--sycm-font-size-caption); }
.msm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); }
.msm-panel .ant-table-placeholder .ant-table-cell { height: 180px; }
.msm-panel__date { font-size: var(--sycm-font-size-caption); }
.msm-muted { color: var(--sycm-text-tertiary); }
/* 表头口径提示:虚线下划线 + help 光标 */
.msm-th-tip {
border-bottom: 1px dashed var(--sycm-border-strong, var(--sycm-text-tertiary));
cursor: help;
}
.msm-note {
display: block;
max-width: 1180px;
margin-top: 4px;
font-size: 12px;
line-height: 1.6;
}
.msm-sku {
font-family: var(--sycm-font-family-number);
font-weight: var(--sycm-font-weight-semibold);
}
@container modular-stock-monitor (max-width: 900px) {
.msm-filter-card .ant-space-item { flex: 1 1 180px; }
}
@media (max-width: 767px) {
.msm-header {
min-height: 72px;
align-items: flex-start;
flex-direction: column;
gap: var(--sycm-space-sm);
padding-block: 12px;
}
.msm-filter-card .ant-select,
.msm-filter-card .ant-input-affix-wrapper { width: 100%; }
.msm-panel .ant-card-body { padding-inline: 10px; }
}

View File

@ -41,11 +41,13 @@ const { Text } = Typography;
const DRIVER_OPTIONS = [
{ value: 'mysql', label: 'MySQL / MariaDB' },
{ value: 'mssql', label: 'SQL Server (MSSQL)' },
{ value: 'postgres', label: 'PostgreSQL' },
];
const DEFAULT_PORTS = {
mysql: 3306,
mssql: 1433,
postgres: 5432,
};
const CORE_CONFIG_NAMES = new Set([
@ -59,6 +61,7 @@ const CONFIG_LABELS = {
cloud_database_public: '公网业务数据库',
planning_database: '企划数据库',
jiansun_database: '简尚业务数据库',
product_information_database: '产品资料系统数据库',
};
const DatabaseSettings = () => {

View File

@ -1,149 +1,164 @@
import React, { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import BasicLayout from '../layouts/BasicLayout';
import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton';
const Login = lazy(() => import('../pages/Login'));
const Install = lazy(() => import('../pages/Install'));
const Setup = lazy(() => import('../pages/Setup'));
const Settings = lazy(() => import('../pages/Settings'));
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitoring = lazy(() => import('../pages/ModularStockMonitoring'));
const lazyLoadingElement = <PageLoadingFallback />;
const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => {
const LazyView = lazyView;
return (
<Suspense fallback={fallback}>
<LazyView {...props} />
</Suspense>
);
};
const HOME_URL = '/planning/dev-style-preview';
const router = createBrowserRouter([
{
path: '/login',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/callback',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/setup',
element: renderLazyComponent(Setup),
},
{
path: '/install',
element: renderLazyComponent(Install),
},
{
path: '/',
element: <BasicLayout />,
children: [
{
index: true,
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'dashboard',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'channel',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/operation/marketing-events',
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
},
{
path: 'planning/operation/marketing-events/activity-products',
element: renderLazyComponent(MarketingEvents),
},
{
path: 'planning/operation/marketing-tools',
element: <Navigate to="/planning/operation/marketing-events" replace />,
},
{
path: 'planning/operation/product-information',
element: renderLazyComponent(ProductInformation),
},
{
path: 'planning/operation/product-analysis',
element: renderLazyComponent(ProductAnalysis),
},
{
path: 'planning/operation/promotion-module',
element: renderLazyComponent(PromotionModule),
},
{
path: 'planning/operation/ops-b-incentive',
element: renderLazyComponent(OpsBIncentiveBoard),
},
{
path: 'planning/operation/product-monitoring',
element: renderLazyComponent(ProductMonitoring),
},
{
path: 'planning/operation/modular-stock-monitoring',
element: renderLazyComponent(ModularStockMonitoring),
},
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
{
path: 'planning/operation/:featureKey',
element: renderLazyComponent(OperationPlaceholder),
},
{
path: 'planning/upload',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/dev-style-preview',
element: renderLazyComponent(DevelopmentStylePreview),
},
{
path: 'settings',
element: renderLazyComponent(Settings),
},
{
path: 'settings/online-desktop-users',
element: renderLazyComponent(OnlineDesktopUsers),
},
{
path: 'settings/desktop-execution-tasks',
element: renderLazyComponent(DesktopExecutionTasks),
},
{
path: '*',
element: <Navigate to={HOME_URL} replace />,
},
],
},
]);
export default router;
import React, { lazy, Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import BasicLayout from '../layouts/BasicLayout';
import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton';
const Login = lazy(() => import('../pages/Login'));
const Install = lazy(() => import('../pages/Install'));
const Setup = lazy(() => import('../pages/Setup'));
const Settings = lazy(() => import('../pages/Settings'));
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
const ModularStockMonitoring = lazy(() => import('../pages/ModularStockMonitoring'));
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
const lazyLoadingElement = <PageLoadingFallback />;
const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => {
const LazyView = lazyView;
return (
<Suspense fallback={fallback}>
<LazyView {...props} />
</Suspense>
);
};
const HOME_URL = '/planning/dev-style-preview';
const router = createBrowserRouter([
{
path: '/login',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/callback',
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
},
{
path: '/setup',
element: renderLazyComponent(Setup),
},
{
path: '/install',
element: renderLazyComponent(Install),
},
{
path: '/',
element: <BasicLayout />,
children: [
{
index: true,
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'dashboard',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'channel',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/operation/marketing-events',
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
},
{
path: 'planning/operation/marketing-events/activity-products',
element: renderLazyComponent(MarketingEvents),
},
{
path: 'planning/operation/marketing-tools',
element: <Navigate to="/planning/operation/marketing-events" replace />,
},
{
path: 'planning/operation/product-information',
element: renderLazyComponent(ProductInformation),
},
{
path: 'planning/operation/product-analysis',
element: renderLazyComponent(ProductAnalysis),
},
{
path: 'planning/operation/promotion-module',
element: renderLazyComponent(PromotionModule),
},
{
path: 'planning/operation/ops-b-incentive',
element: renderLazyComponent(OpsBIncentiveBoard),
},
{
path: 'planning/operation/product-monitoring',
element: renderLazyComponent(ProductMonitoring),
},
{
path: 'planning/operation/modular-stock-monitor',
element: renderLazyComponent(ModularStockMonitor),
},
{
path: 'planning/operation/modular-stock-monitoring',
element: renderLazyComponent(ModularStockMonitoring),
},
{
path: 'planning/operation/mkh-bom-data',
element: renderLazyComponent(MkhBomData),
},
{
path: 'planning/operation/daily-inventory',
element: renderLazyComponent(DailyInventory),
},
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
{
path: 'planning/operation/:featureKey',
element: renderLazyComponent(OperationPlaceholder),
},
{
path: 'planning/upload',
element: <Navigate to={HOME_URL} replace />,
},
{
path: 'planning/dev-style-preview',
element: renderLazyComponent(DevelopmentStylePreview),
},
{
path: 'settings',
element: renderLazyComponent(Settings),
},
{
path: 'settings/online-desktop-users',
element: renderLazyComponent(OnlineDesktopUsers),
},
{
path: 'settings/desktop-execution-tasks',
element: renderLazyComponent(DesktopExecutionTasks),
},
{
path: '*',
element: <Navigate to={HOME_URL} replace />,
},
],
},
]);
export default router;

View File

@ -0,0 +1,15 @@
export const MKH_FRESH_DATA_CONTRACT_VERSION = 1;
const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。'
+ '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。';
export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) {
const contractVersion = Number(snapshot?.freshDataContractVersion) || 0;
if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) {
throw new Error(OUTDATED_BACKEND_MESSAGE);
}
if (requireApplied && snapshot?.freshDataApplied !== true) {
throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。');
}
return snapshot;
}

View File

@ -0,0 +1,17 @@
export function createRequestGate() {
let generation = 0;
return {
begin() {
generation += 1;
return generation;
},
invalidate() {
generation += 1;
return generation;
},
isCurrent(requestGeneration) {
return requestGeneration === generation;
},
};
}

View File

@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js';
test('MKH fresh contract accepts a backend-confirmed live refresh', () => {
const snapshot = { freshDataContractVersion: 1, freshDataApplied: true };
assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot);
});
test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => {
assert.throws(
() => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }),
/本地后端版本过旧/
);
});
test('MKH fresh contract requires explicit refresh completion for fresh requests', () => {
assert.throws(
() => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }),
/未确认已重读/
);
});

View File

@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRequestGate } from '../src/utils/requestGate.js';
test('only the newest request may update the page', () => {
const gate = createRequestGate();
const oldRequest = gate.begin();
const newRequest = gate.begin();
assert.equal(gate.isCurrent(oldRequest), false);
assert.equal(gate.isCurrent(newRequest), true);
});
test('sync invalidates every request that started before cache clearing', () => {
const gate = createRequestGate();
const beforeSync = gate.begin();
gate.invalidate();
assert.equal(gate.isCurrent(beforeSync), false);
const freshSnapshot = gate.begin();
assert.equal(gate.isCurrent(freshSnapshot), true);
});

View File

@ -11,15 +11,21 @@ export default defineConfig({
cssCodeSplit: false,
modulePreload: false,
rollupOptions: {
onwarn(warning, warn) {
// Manually splitting packages that depend on each other can create a circular chunk graph.
// That graph built successfully in the past but crashed before React mounted, leaving only
// the static skeleton on screen. Treat it as a release-blocking error from now on.
if (warning.code === 'CIRCULAR_CHUNK') {
throw new Error(`Circular production chunk: ${warning.message}`)
}
warn(warning)
},
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames(chunkInfo) {
if (chunkInfo.name === 'app-shell') return 'assets/app-shell-[hash].js'
if (chunkInfo.name === 'app-api') return 'assets/app-api-[hash].js'
if (chunkInfo.name === 'react-vendor') return 'assets/react-vendor-[hash].js'
if (chunkInfo.name === 'antd-vendor') return 'assets/antd-vendor-[hash].js'
if (chunkInfo.name === 'charts-vendor') return 'assets/charts-vendor-[hash].js'
if (chunkInfo.name === 'data-vendor') return 'assets/data-vendor-[hash].js'
if (chunkInfo.name === 'vendor') return 'assets/vendor-[hash].js'
return 'assets/[name]-[hash].js'
},
assetFileNames(assetInfo) {
@ -32,41 +38,8 @@ export default defineConfig({
const normalizedId = id.replace(/\\/g, '/')
if (normalizedId.includes('node_modules') || normalizedId.includes('/.vite/deps/')) {
if (
normalizedId.includes('/react/') ||
normalizedId.includes('react-dom') ||
normalizedId.includes('react-router') ||
normalizedId.includes('@remix-run/router') ||
normalizedId.includes('react_jsx-runtime') ||
normalizedId.includes('scheduler')
) {
return 'react-vendor'
}
if (
normalizedId.includes('antd') ||
normalizedId.includes('@ant-design') ||
normalizedId.includes('@ant-design/pro-components') ||
normalizedId.includes('/rc-')
) {
return 'antd-vendor'
}
if (
normalizedId.includes('echarts') ||
normalizedId.includes('zrender') ||
normalizedId.includes('recharts')
) {
return 'charts-vendor'
}
if (
normalizedId.includes('axios') ||
normalizedId.includes('@cubejs-client')
) {
return 'data-vendor'
}
// Keep mutually dependent third-party packages in one chunk. React, Ant Design,
// charts and Cube clients share runtime helpers and cannot be split safely by path.
return 'vendor'
}

View File

@ -11,13 +11,57 @@ const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
/**
* 解析后端实际监听端口。
* 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。
* 前端 dev server 的 /api 代理必须指向同一端口,否则请求会被转发到空端口。
*/
function resolveBackendPort() {
const fromEnv = Number(process.env.CANGKU_BACKEND_PORT);
if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) {
return fromEnv;
}
const envPath = path.join(ROOT_DIR, 'backend', '.env');
if (fs.existsSync(envPath)) {
const matched = fs.readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/mu);
const fromFile = Number(matched?.[1]);
if (Number.isInteger(fromFile) && fromFile > 0 && fromFile < 65536) {
return fromFile;
}
}
return 4001;
}
/**
* 隧道重连退避:1s → 2s → 4s … 封顶 30s。
* 首次失败也立即重试(等价 autossh 的 AUTOSSH_GATETIME=0),避免网络尚未就绪时白等。
*/
const TUNNEL_RETRY_BASE_MS = 1000;
const TUNNEL_RETRY_MAX_MS = 30000;
const TUNNEL_RETRY_EXPONENT_CAP = 10;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function computeTunnelRetryDelay(attempt) {
const exponent = Math.min(attempt, TUNNEL_RETRY_EXPONENT_CAP);
return Math.min(TUNNEL_RETRY_BASE_MS * 2 ** exponent, TUNNEL_RETRY_MAX_MS);
}
export function buildSshArgs(config) {
return [
'-N', '-T',
'-o', 'BatchMode=yes',
// 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止;
// 真正的根治是下面的退避重连循环。
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveInterval=15',
'-o', 'ServerAliveCountMax=3',
'-o', 'TCPKeepAlive=yes',
'-o', 'ConnectTimeout=10',
'-o', 'StrictHostKeyChecking=yes',
'-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`,
'-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`,
@ -89,32 +133,88 @@ export async function main(mode = process.argv[2] || 'tunnel') {
process.once(signal, stopChildren);
}
let tunnelRetryAttempt = 0;
let resolveTunnelReady = null;
const tunnelReady = new Promise((resolve) => {
resolveTunnelReady = resolve;
});
/**
* 启动一条 SSH 隧道并等它监听就绪。
* 返回 { tunnel, exitPromise };隧道结束由 exitPromise 上报退出码,交给
* superviseTunnel 决定重连 —— 这里**不再**牵连后端/前端。
*/
const startTunnel = async () => {
const { value: config } = loadConnectionConfig(ROOT_DIR);
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
children.add(tunnel);
let ready = false;
const earlyExit = new Promise((resolve, reject) => {
const exitPromise = new Promise((resolve) => {
tunnel.once('exit', (code) => {
children.delete(tunnel);
if (!ready) {
reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`));
} else if (!shuttingDown) {
console.error(`SSH 隧道已退出(${code ?? 'unknown'})`);
stopChildren();
process.exitCode = code || 1;
}
resolve(code);
});
});
try {
await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]);
ready = true;
} catch (error) {
// 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection
const outcome = await Promise.race([
waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })),
exitPromise.then((code) => ({ ready: false, code })),
]);
if (!outcome.ready) {
if (!tunnel.killed) tunnel.kill('SIGTERM');
throw error;
throw new Error(`SSH 隧道在建立前退出(${outcome.code ?? 'unknown'})`);
}
console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`);
return tunnel;
return { tunnel, exitPromise };
};
/**
* 隧道自愈循环(Windows 版 autossh 的语义)。
*
* 隧道断开只重连隧道本身,后端/前端进程保持不动。这是原先缺失的容错:
* 旧实现在隧道 exit 时直接 stopChildren(),把整个开发栈一起杀掉,于是每一次
* 远端 RST 都升级成一次「网站打不开」,还得靠外部守护清残留、整树重启。
*
* 建立失败走指数退避(1s→30s);建立成功后重置退避,断开则固定 1s 后重连。
*/
const superviseTunnel = async () => {
for (;;) {
if (shuttingDown) return;
let session;
try {
session = await startTunnel();
tunnelRetryAttempt = 0;
if (resolveTunnelReady) {
resolveTunnelReady();
resolveTunnelReady = null;
}
} catch (error) {
if (shuttingDown) return;
const delay = computeTunnelRetryDelay(tunnelRetryAttempt);
tunnelRetryAttempt += 1;
console.error(`SSH 隧道建立失败(${error.message}),${delay}ms 后重试`);
await sleep(delay);
continue;
}
const code = await session.exitPromise;
if (shuttingDown) return;
console.error(`SSH 隧道已退出(${code ?? 'unknown'}),正在重连…(后端/前端不受影响)`);
await sleep(TUNNEL_RETRY_BASE_MS);
}
};
// 守护循环本身理论上不抛错,兜底仍保留:真异常时按老路径收尾,避免留下半死进程
const startTunnelSupervisor = () => {
superviseTunnel().catch((error) => {
console.error(`隧道守护异常终止:${error.message}`);
stopChildren();
process.exitCode = 1;
});
};
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
@ -124,12 +224,14 @@ export async function main(mode = process.argv[2] || 'tunnel') {
CANGKU_REMOTE_SUPERVISOR: 'true',
};
if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) {
childEnvironment.VITE_API_PROXY_TARGET = `http://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`;
childEnvironment.VITE_API_PROXY_TARGET = `http://localhost:${resolveBackendPort()}`;
}
const child = spawn(npmCommand, args, {
cwd: ROOT_DIR,
stdio: 'inherit',
env: childEnvironment,
// Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL
shell: process.platform === 'win32',
});
children.add(child);
child.once('exit', (code) => {
@ -147,7 +249,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
const hasConnection = fs.existsSync(CONNECTION_PATH);
if (hasConnection) {
if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH);
await startTunnel();
startTunnelSupervisor();
await tunnelReady;
} else if (mode === 'tunnel') {
throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key');
}
@ -170,7 +273,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
await waitForExit(backend);
restartingBackend = false;
fs.unlinkSync(RESTART_MARKER_PATH);
await startTunnel();
startTunnelSupervisor();
await tunnelReady;
backend = startApplication(
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
'backend'

View File

@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs';
import { buildSshArgs } from '../scripts/start-remote.mjs';
import { buildSshArgs, computeTunnelRetryDelay } from '../scripts/start-remote.mjs';
const enrollment = {
installation_id: 'dev-123',
@ -69,3 +70,61 @@ test('pins standard and non-standard SSH host targets', () => {
assert.equal(getKnownHostsLine('example.test', 22, 'ssh-ed25519 KEY'), 'example.test ssh-ed25519 KEY\n');
assert.equal(getKnownHostsLine('example.test', 2222, 'ssh-ed25519 KEY'), '[example.test]:2222 ssh-ed25519 KEY\n');
});
// ─────────────────────────────────────────────────────────────────────────────
// 隧道自愈(根治远端 RST 导致「网站打不开」)
//
// 旧行为:隧道进程 exit → stopChildren(),把后端 + 前端一起杀掉,于是每一次远端
// RST 都升级成一次整栈不可用,还得靠外部守护清残留、整树重启。
// 新行为:隧道 exit → 只重连隧道本身,应用进程不受影响。
// ─────────────────────────────────────────────────────────────────────────────
const tunnelConfig = {
localDatabasePort: 13307,
ssh: {
host: 'example.test',
port: 50232,
user: 'cangku-tunnel',
remoteDatabaseHost: '127.0.0.1',
remoteDatabasePort: 3307,
},
};
test('tunnel keepalive: 更快发现死连接,且不会在 connect 阶段无限等待', () => {
const flags = new Set(buildSshArgs(tunnelConfig));
// ExitOnForwardFailure 缺失会出现最阴险的故障:进程活着、端口却没绑上(「静默成功」)
assert.ok(flags.has('ExitOnForwardFailure=yes'), '必须保留 ExitOnForwardFailure=yes');
assert.ok(flags.has('ServerAliveInterval=15'), '探活间隔应为 15s,以便更快发现被 RST 的连接');
assert.ok(flags.has('ServerAliveCountMax=3'));
assert.ok(flags.has('TCPKeepAlive=yes'), '需要内核层保活兜底');
assert.ok(flags.has('ConnectTimeout=10'), '连不上时应快速失败,交给退避重连而不是干等');
});
test('tunnel retry backoff: 1s 起指数增长,封顶 30s 且不会溢出', () => {
assert.equal(computeTunnelRetryDelay(0), 1000);
assert.equal(computeTunnelRetryDelay(1), 2000);
assert.equal(computeTunnelRetryDelay(2), 4000);
assert.equal(computeTunnelRetryDelay(4), 16000);
assert.equal(computeTunnelRetryDelay(5), 30000);
// 长期不可用时 attempt 会一路增长,必须封顶而不是算成 Infinity / 0
assert.equal(computeTunnelRetryDelay(200), 30000);
assert.ok(Number.isFinite(computeTunnelRetryDelay(2000)));
});
test('防腐化:隧道自愈循环不得停止后端/前端进程', async () => {
const source = await readFile(new URL('../scripts/start-remote.mjs', import.meta.url), 'utf8');
const start = source.indexOf('const superviseTunnel = async () => {');
const end = source.indexOf('const startTunnelSupervisor = () => {');
assert.ok(start > 0 && end > start, '未定位到 superviseTunnel 实现');
const body = source.slice(start, end);
assert.equal(
body.includes('stopChildren()'),
false,
'隧道重连不得牵连后端/前端 —— 否则又回到「隧道一断,整个开发栈陪葬」'
);
assert.ok(
body.includes('await sleep('),
'隧道退出后必须先等待再重连,避免 RST 时疯狂重连打爆对端'
);
});

View File

@ -51,6 +51,21 @@ git status --short
- 数据库迁移使用不重复的名称,兼容已有数据,不能修改已经执行的历史迁移来代替新增迁移。
- 不提交 `.env`、私钥、令牌、真实业务数据、数据库备份、`node_modules` 或无关构建产物。示例配置只使用占位值。
### 3.1 数据表、索引与映射负载要求
开发者及 AI 新建或修改数据表、第三方数据映射、查询接口、报表或同步任务时,必须把数据库负载作为功能验收条件,不得等到上线后再补索引或限流。
1. **按真实访问路径设计索引。** 根据 `WHERE`、`JOIN`、`ORDER BY`、分页、去重和幂等键设计单列或联合索引,明确联合索引列顺序和最左匹配规则。索引列的类型、长度、字符集和排序规则必须与关联列一致。不要添加没有查询依据的重复索引,也不能以索引过多换取“可能更快”。
2. **映射关系必须稳定且可幂等。** 第三方主键、业务编码与系统主键应使用明确的标准化字段和唯一约束,需要多字段定位时建立与匹配顺序一致的联合唯一索引。禁止每次请求通过模糊匹配、字符串函数或全表扫描重建映射。
3. **提交前验证执行计划。** 在隔离的测试环境使用 `EXPLAIN`;数据库支持且确认语句安全时可使用 `EXPLAIN ANALYZE`。检查实际命中索引、预估/实际扫描行数、回表、排序和临时表情况。不得在生产库上执行高负载的 `EXPLAIN ANALYZE` 或压测。
4. **查询必须有界。** 使用限制列的字段选择和有上限的分页,大数据量优先使用基于稳定索引键的游标分页。避免 `SELECT *`、N+1 查询、无上限 `IN`、前置通配符模糊查询、对索引列包裹函数,以及没有范围条件的更新或删除。
5. **同步任务必须分批、增量和可恢复。** 通过时间水位、版本号或稳定主键做增量读取,用唯一约束与 upsert 保证幂等;设置可控的批量、并发数、查询超时、重试退避和单实例锁。禁止每次启动全量扫描、无限并发、紧循环轮询或重复后台任务。
6. **保护第三方云数据库。** 业务云库查询必须只取必要列和必要范围,并在系统库持久化必要的结果或增量水位,避免页面请求反复扫描远程大表。未获明确授权不得在第三方库执行 DDL、建索引或写入。
7. **迁移必须考虑锁表和回退。** 针对大表新增索引、变更列类型或回填数据前,评估表规模、锁等待、磁盘增长和业务窗口;采用可分步的兼容迁移,并说明回退时是否会丢数据。
8. **PR 必须附上数据库证据。** 说明表规模假设、索引定义及对应查询、执行计划摘要、扫描行数或耗时对比、同步批量/并发/超时参数、迁移与回退方式。无法获得有代表性数据时要标明限制,不得虚构性能结论。
只有功能结果正确且负载证据可接受,数据表和查询变更才算完成。无法证明索引有效、可能对中央系统库或业务云库造成高负载时,保持 PR 待审核,不得部署。
提交前逐项检查,并显式暂存文件;文件中混有不同任务时使用 `git add -p` 选择自己的改动。不要盲目使用 `git add .` 或 `git add -A`:
```bash
@ -153,12 +168,12 @@ npm --prefix frontend run check
合并要求:
1. 至少一名非提交者的维护者或开发者审核最终差异;AI 自我复查可以辅助,但不代替独立审核。提交 AI 不得用另一个会话或共享账号冒充独立审核者。
1. 默认由至少一名非提交者的维护者或开发者审核最终差异;AI 自我复查可以辅助,但不代替独立审核。仓库所有者或管理员对明确的 PR 作出书面合并指令时,该授权可以代替独立审核,但必须在 PR 中记录授权人、授权范围和当时的最终提交 SHA。提交 AI 不得伪造授权、冒用其他账号或把模糊的日常开发指令解释为合并授权。
2. 审核需关注是否撤销他人逻辑、丢失配置/依赖、改变接口与权限、破坏已上线功能,不能只看有无冲突或测试绿灯。
3. 审核和检查必须对应最终待合并提交;后续新提交或冲突解决后需要重新确认,不能沿用失效的批准。
4. 合并前再次确认最新 `main` 已被纳入任务分支,并验证组合后的行为。其他 PR 先合并导致主分支前进时,更新本 PR、重新检查并确认审核后再合并。
3. 审核、所有者/管理员授权和检查必须对应最终待合并提交;后续新增业务代码、依赖变更或冲突解决后需要重新确认,不能沿用失效的批准。授权后仅补充不改变行为的合并记录或文档时,应在 PR 中说明并重新检查最终差异。
4. 合并前再次确认最新 `main` 已被纳入任务分支,并验证组合后的行为。其他 PR 先合并导致主分支前进时,更新本 PR、重新检查并确认审核或所有者/管理员授权仍适用于最终提交后再合并。
5. 由维护者通过仓库 PR 合并功能依次处理;同时有多个待合并 PR 时串行合并。有合并队列时使用针对最新组合提交的检查,避免两份旧验证同时放行。
6. 未解决冲突、必需检查失败、尚未审核或同步状态不清楚时不得合并。缺少独立审核者时保持 PR 待审核,不自行批准。
6. 未解决冲突、必需检查失败、同步状态不清楚,或既无独立审核也无所有者/管理员明确授权时不得合并。授权例外不允许绕过失败检查、未解决冲突、数据库风险证据或分支保护,也不允许直接推送 `main`。
7. 合并后核对主分支结果。删除任务分支前确认已合并且无人继续使用,不删除其他开发者的分支。
## 7. 发现误合并或代码被覆盖
@ -172,8 +187,10 @@ npm --prefix frontend run check
- [ ] 已阅读本规范;当前仓库、目录、账号、任务分支和基线正确。
- [ ] 没有覆盖、清理、暂存或提交他人的未提交修改。
- [ ] 提交仅包含本任务内容,没有秘密信息、真实业务数据或无关产物。
- [ ] 涉及数据表、索引、映射、查询或同步时,已验证执行计划、数据量边界和负载控制,并在 PR 记录真实证据。
- [ ] 已同步最新主分支及远端任务分支,未强推、未改写已发布历史。
- [ ] 已逐项审核最终差异;冲突处理保留双方有效行为,疑点已确认。
- [ ] 已取得独立审核,或已在 PR 中记录仓库所有者/管理员针对最终提交的明确合并授权。
- [ ] 已完成受影响的验证,记录准确结果和提交 SHA。
- [ ] PR 已说明范围、兼容性、验证、风险和回退方法。
- [ ] 已明确报告当前状态:本地提交 / 已推送 / PR 待审核 / 已合并;没有把推送成功说成合并或部署成功。
@ -182,6 +199,6 @@ npm --prefix frontend run check
本文件规定协作流程,`AGENTS.md` 提供 AI 读取入口;它们不是权限控制,不能保证每个 AI 工具自动读取或遵守。使用不自动读取这些文件的工具时,开发者必须主动提供规则。
仓库管理员应在服务端落实以下保护,并以实际设置为准:保护 `main`、禁止直接及强制推送和删除、要求 PR 与独立审核、使过期审核失效、绑定真实运行的必需检查、限制合并权限、按个人发放最小权限凭据。没有真正运行的检查不能仅凭同名状态作为合并依据。
仓库管理员应在服务端落实以下保护,并以实际设置为准:保护 `main`、禁止直接及强制推送和删除、要求通过 PR 合并、使过期审核失效、绑定真实运行的必需检查、限制合并权限、按个人发放最小权限凭据。团队可默认要求独立审核;需要允许所有者/管理员授权例外时,应确保只有真实所有者或管理员能够执行并保留可审计记录。没有真正运行的检查不能仅凭同名状态作为合并依据。
**添加本文件不等于上述保护已经启用。** 此文档变更不修改仓库权限、分支保护、CI 或生产部署;如需启用服务端限制,应由管理员单独配置并验证。

View File

@ -7,7 +7,8 @@
- 生产服务器项目根目录:`/opt/cangku`
- 服务器代码目录:`/opt/cangku/app`
- 生产编排文件:`/opt/cangku/app/deploy/production/docker-compose.yml`
- 对外站点及注册接口:`https://ck.yydd.ai:8443`
- 对外主站:`https://ck.yydd.ai`(标准 HTTPS 端口 `443`)
- 兼容访问入口及注册接口:`https://ck.yydd.ai:8443`
- 公网 TLS Redis:`ck.yydd.ai:6380`
- 服务器 SSH 密钥目录位于开发者桌面:`~/Desktop/cangku-156.225.27.52-ssh-key`
- 当前私钥文件:`~/Desktop/cangku-156.225.27.52-ssh-key/cangku-prod-root-ed25519`
@ -59,8 +60,22 @@ git diff --stat origin/main...HEAD
2. 本地已有修改先按 `仓库提交与合并规范.md` 提交到唯一任务分支,再同步最新 `origin/main`。
3. 如果主仓库已有更新,先合并到任务分支,逐处处理冲突,重新检查最终差异并运行受影响测试。
4. 禁止使用强制推送、`git reset --hard`、批量选择 `ours/theirs` 或复制旧目录覆盖新代码来解决冲突。
5. 将任务分支推送到主仓库,创建 PR,经过审核和验证后合并到 `main`。这里的“先推送主仓库”指推送任务分支并通过 PR 合并,禁止直接推送 `main`。
6. PR 合并后再次执行 `git fetch origin`,记录准备部署的 `origin/main` 完整 SHA。部署 SHA 必须与审核通过的最终提交一致。
5. 将任务分支推送到主仓库并创建 PR;经过独立审核,或取得仓库所有者/管理员针对该 PR 最终提交的明确授权,并完成验证后合并到 `main`。这里的“先推送主仓库”指推送任务分支并通过 PR 合并,禁止直接推送 `main`。
6. PR 合并后再次执行 `git fetch origin`,记录准备部署的 `origin/main` 完整 SHA。部署 SHA 必须与审核或所有者/管理员授权覆盖的最终提交一致。
如果本次变更涉及数据表、索引、查询、数据映射或同步任务,部署者还必须确认 PR 已按《仓库提交与合并规范》记录索引与执行计划证据、分批/并发/超时边界、大表锁影响和回退方式。证据缺失、依赖全表扫描、可能对系统库或第三方云库造成不可控负载时,不得部署。
### 3.1 前端发布必须阻断循环分块
本项目曾因 Vite/Rollup 把 React、Ant Design、图表库和数据客户端按包路径拆成相互依赖的多个 chunk,生产构建虽然成功,浏览器却在 React 挂载前报错,页面永久停留在静态骨架屏。`curl` 返回 `200`、后端 `/health` 正常和静态资源可下载都无法发现这一故障。
前端相关变更必须遵守以下要求:
1. 保留 `frontend/vite.config.js` 中对 `CIRCULAR_CHUNK` 的构建阻断。不得为了让构建“通过”而删除、降级或忽略该检查。
2. 第三方依赖默认保留在同一个 `vendor` chunk。不得仅按目录名把 React、Ant Design、图表库或 Cube/数据客户端强行拆成独立 vendor chunk;确需重新分包时,必须证明 chunk 依赖图无环,并完成本节和第 6 节的浏览器运行时验证。
3. 出现 `Circular chunk`、`Cannot read properties of undefined`、`Cannot access ... before initialization` 等构建或浏览器错误时,停止发布并修正模块依赖或分包策略。禁止把它当成普通警告继续部署。
4. 单个 vendor 文件较大的提示不等于可以直接按包名拆分。先使用路由级动态导入、页面级懒加载或经过验证的稳定边界减小首屏体积。
5. 推送前至少执行 `npm --prefix frontend run check`。构建日志必须没有循环 chunk 警告;测试成功不能替代真实生产构建验证。
有冲突时,AI 必须阅读共同基线和双方改动,保留双方仍然有效的行为。无法确定业务取舍时,停止该冲突的处理并请负责人决定。冲突解决后的代码必须重新测试、重新审核,不能沿用冲突前的结果。
@ -110,15 +125,14 @@ test "$(git rev-parse "origin/main^{commit}")" = "$DEPLOY_SHA" || {
exit 1
}
git switch main
git checkout main
git pull --ff-only origin main
test "$(git rev-parse HEAD)" = "$DEPLOY_SHA" || {
echo '服务器 HEAD 与待部署 SHA 不一致,停止部署'
exit 1
}
npm --prefix frontend ci
npm --prefix frontend run build
bash deploy/production/build-frontend.sh
if grep -q '^SOURCE_GIT_SHA=' /opt/cangku/shared/backend.env; then
sed -i "s/^SOURCE_GIT_SHA=.*/SOURCE_GIT_SHA=$DEPLOY_SHA/" /opt/cangku/shared/backend.env
else
@ -143,8 +157,10 @@ for attempt in $(seq 1 60); do
sleep 2
done
curl -fsS https://ck.yydd.ai/ >/dev/null
curl -fsS https://ck.yydd.ai/health
curl -fsS https://ck.yydd.ai:8443/ >/dev/null
curl -fsS https://ck.yydd.ai:8443/health
curl -fsS https://ck.yydd.ai:8443/health >/dev/null
docker compose -f deploy/production/docker-compose.yml ps
git status --short
git rev-parse HEAD
@ -157,12 +173,25 @@ REMOTE
## 6. 部署完成标准
### 6.1 必须验证浏览器真实运行,不能只检查 HTTP 200
前端构建和容器启动后,必须使用一个不复用旧站点缓存和旧登录状态的全新浏览器上下文访问主站 `https://ck.yydd.ai/`。可以使用临时 Chrome 用户目录、全新的无痕窗口,或在开发者工具中清空站点数据后硬刷新。验证过程必须同时满足:
1. 页面离开启动骨架屏,显示完整登录页或登录后的业务页面。
2. 浏览器 Console 没有未捕获的 `TypeError`、`ReferenceError`、模块初始化错误或意外的资源加载错误。登录页进行会话探测时,鉴权接口按设计返回 `401` 属于正常结果,但必须确认请求目标和响应含义确实是“尚未登录”。
3. Network 中入口 HTML 和当前 HTML 引用的 JS/CSS 必须成功,关键接口响应必须符合当前登录状态与业务预期;不得只验证首页 HTML 返回 `200`。
4. 当前 HTML 引用的资源文件名来自本次构建,页面展示的版本信息或接口 `/health` 中的 `SOURCE_GIT_SHA` 与待部署 SHA 一致。
5. 至少完成一个关键用户路径;涉及数据页面时,要确认加载能结束并显示数据、空状态或明确错误,不能无限显示 loading/skeleton。
如果全新浏览器上下文正常,而原有标签页仍停在旧骨架屏,先关闭旧标签并进行硬刷新或清除该站点缓存。不得通过回滚代码、固定旧资源文件名或长期关闭缓存来掩盖客户端旧资源。若全新上下文也失败,保留 Console、Network、容器日志和当前 SHA,在部署锁内停止报告完成并修复后重新发布。
部署者和 AI 只有在以下项目全部确认后才能报告完成:
- 服务器 `HEAD` 等于预先记录且已合并的 `origin/main` SHA。
- `git status --short` 没有输出。
- MySQL、Redis、Redis TLS 网关、后端和注册服务容器处于正常运行状态。
- `https://ck.yydd.ai:8443/` 返回成功,`/health` 显示系统数据库、第三方数据库和 Redis 连接成功。
- `https://ck.yydd.ai/` 与 `https://ck.yydd.ai:8443/` 返回成功,`/health` 显示系统数据库、第三方数据库和 Redis 连接成功。
- 使用全新浏览器上下文完成第 6.1 节检查;页面已离开骨架屏,Console 无致命运行时错误,关键数据页面不会无限加载。
- `ck.yydd.ai:6380` 的 TLS 证书校验通过;原始 Redis 端口没有对公网开放。
- 新版本的关键用户路径已验证,没有覆盖其他开发者已经上线的功能。
- 最终报告包含任务分支、PR、部署 SHA、实际检查结果和未解决问题。