feat: 新增模块化库存监控(全仓口径 + 实时库存) #39

Open
wangjie wants to merge 3 commits from codex/modular-stock-monitor-0924 into main
11 changed files with 1340 additions and 0 deletions

View File

@ -0,0 +1,163 @@
'use strict';
// 聚水潭「加工单查询」接入自检脚本
//
// 用法:
// cd backend
// node scripts/jst-manufacture-probe.js # 查最近 7 天
// node scripts/jst-manufacture-probe.js 14 # 查最近 14 天(内部按 7 天分段)
// node scripts/jst-manufacture-probe.js 7 904533 # 额外按指定加工单号探测
//
// 作用:
// 1. 校验 JST_APP_KEY / JST_APP_SECRET / JST_ACCESS_TOKEN 是否可用
// 2. 自动探测 4 种签名变体,告诉你 JST_SIGN_VARIANT 该填哪个
// 3. 打印首屏加工单原始结构(含 items 明细的 sku_id / qty / io_qty),用于确认「在途数量」取值口径
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const jushuitan = require('../src/services/jushuitanClient');
const MANUFACTURE_PATH = '/open/jushuitan/manufacture/query';
const PAGE_SIZE = 50;
function pad(value) {
return String(value).padStart(2, '0');
}
function formatDateTime(date) {
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
);
}
function printConfig() {
const info = jushuitan.describeConfig();
console.log('== 凭证 ==');
console.log(` API 地址 : ${info.baseUrl}`);
console.log(` app_key : ${info.appKey}`);
console.log(` app_secret : ${info.appSecret}`);
console.log(` access_token : ${info.accessToken}`);
console.log(` 当前签名变体 : ${info.signVariant}`);
console.log(` 是否已配置完整: ${info.configured}`);
console.log('');
if (!info.configured) {
console.log('✗ 凭证不完整,请先在 backend/.env 里补上 JST_APP_KEY / JST_APP_SECRET / JST_ACCESS_TOKEN');
return false;
}
return true;
}
function buildParams({ startTime, endTime, pageIndex = 1, poId = null }) {
const params = {
page_index: pageIndex,
page_size: PAGE_SIZE,
date_type: 2, // 2 = 加工日期
start_time: startTime,
end_time: endTime,
};
if (poId) {
delete params.start_time;
delete params.end_time;
params.po_id = poId;
}
return params;
}
function summarizePage(payload) {
const data = payload && payload.data ? payload.data : payload;
const list = (data && (data.datas || data.items || data.list)) || [];
console.log(` 返回条数: ${Array.isArray(list) ? list.length : '未知'}`);
if (!Array.isArray(list) || list.length === 0) return;
const first = list[0];
console.log(' 首条加工单字段:', Object.keys(first).join(', '));
console.log(' 首条摘要:', JSON.stringify({
po_id: first.po_id,
po_date: first.po_date,
seller: first.seller,
status: first.status,
sub_type: first.sub_type,
wms_co_id: first.wms_co_id,
receipt_wms_co_id: first.receipt_wms_co_id,
}));
const items = first.items || first.items_json || [];
if (Array.isArray(items) && items.length) {
console.log(' items 字段:', Object.keys(items[0]).join(', '));
console.log(' 首条 items 前 2 项:', JSON.stringify(items.slice(0, 2)));
} else {
console.log(' ⚠️ 首条没有 items 数组,完整结构如下(截断 800 字):');
console.log(JSON.stringify(first).slice(0, 800));
}
}
async function main() {
console.log('=== 聚水潭「加工单查询」自检 ===\n');
if (!printConfig()) process.exitCode = 1;
const days = Math.max(1, Number.parseInt(process.argv[2] || '7', 10) || 7);
const poId = (process.argv[3] || '').trim() || null;
const end = new Date();
const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000);
const startTime = formatDateTime(start);
const endTime = formatDateTime(end);
console.log(`== 查询窗口 == ${startTime} ~ ${endTime}(date_type=2 加工日期)\n`);
console.log('== 签名变体探测 ==');
let result;
try {
result = await jushuitan.probeSignVariants(
MANUFACTURE_PATH,
buildParams({ startTime, endTime, pageIndex: 1 })
);
} catch (error) {
console.log(`✗ 调用异常: ${error.message}`);
process.exitCode = 1;
return;
}
for (const attempt of result.attempts) {
const mark = attempt.ok ? '✓ 成功' : '✗ 失败';
console.log(` ${attempt.variant.padEnd(11)} ${mark} ${attempt.error ? `- ${attempt.error}` : ''}`);
}
if (!result.variant) {
console.log('\n✗ 4 种签名变体全部失败,请检查:app_key/app_secret 是否正确、access_token 是否过期、应用是否有「加工单查询」权限');
process.exitCode = 1;
return;
}
console.log(`\n✓ 有效签名变体: ${result.variant}`);
console.log(` 请在 backend/.env 写入: JST_SIGN_VARIANT=${result.variant}\n`);
console.log('== 数据预览 ==');
summarizePage(result.payload);
console.log('');
if (poId) {
console.log(`== 按加工单号探测 po_id=${poId} ==`);
try {
const byId = await jushuitan.callApi(MANUFACTURE_PATH, {
page_index: 1,
page_size: PAGE_SIZE,
po_id: poId,
});
summarizePage(byId);
} catch (error) {
console.log(` ✗ ${error.message}`);
}
console.log('');
}
console.log('== 下一步 ==');
console.log(' 1) 把有效签名变体写入 backend/.env 的 JST_SIGN_VARIANT');
console.log(' 2) 确认 items 里的 qty / io_qty 口径(在途 ≈ qty - io_qty)');
console.log(' 3) 告诉我结果,我接上库存明细表的「采购在途数」并做定时同步');
}
main().catch((error) => {
console.error(`脚本异常: ${error && error.stack ? error.stack : error}`);
process.exitCode = 1;
});

View File

@ -0,0 +1,106 @@
'use strict';
const express = require('express');
const {
DEFAULT_WAREHOUSE,
getModularStockList,
getWarehouseOptions,
} = require('../services/modularStockMonitoringService');
const router = express.Router();
const CONNECTIVITY_ERROR_CODES = new Set([
'ESOCKET', 'ETIMEOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTOPEN', 'ECONNCLOSED', 'ENOTFOUND', 'EAI_AGAIN',
]);
const COOLDOWN_MS = 15000; // 与 cloudDatabase.js 的 CONNECT_FAILURE_COOLDOWN_MS 保持一致
function isConnectivityError(error) {
const code = error?.code || error?.originalError?.code;
if (CONNECTIVITY_ERROR_CODES.has(code)) return true;
const message = String(error?.message || '').toLowerCase();
return [
'failed to connect',
'could not connect',
'connection lost',
'socket hang up',
'connection is closed',
'timed out',
'timeout',
].some((token) => message.includes(token));
}
// 仅在「连接建立失败」或「冷静期内」两种情形下提示倒计时,避免在 SQL 语法等业务错误上误导用户
function getCooldownRemainingMs(error) {
if (error?.cloudDbConnectFailure) {
return COOLDOWN_MS;
}
return 0;
}
function describeError(error, fallback) {
const raw = String(error?.message || fallback);
if (raw.includes('业务数据库尚未配置')) {
return {
error: '业务数据库尚未配置',
hint: '请在「系统设置 -> 业务数据库」中完成配置后重试。',
cooldownSeconds: 0,
};
}
if (isConnectivityError(error)) {
const runtime = error?.cloudDbRuntime || {};
const endpoint = runtime.host ? `${runtime.host}:${runtime.port || 1433}` : '业务数据库';
const port = runtime.port || 1433;
const cooldownMs = getCooldownRemainingMs(error);
const seconds = cooldownMs > 0 ? Math.ceil(cooldownMs / 1000) : 0;
return {
error: `无法连接业务数据库(${endpoint})`,
hint: `排查方向:① 当前网络可能未加入数据库访问白名单——常见现象是能 ping 通主机但 ${port} 端口被防火墙拦截;`
+ '② 业务数据库通常只在公司内网可达,需在能访问它的内网机器 / 服务器上运行本系统。'
+ '请联系数据库管理员将本机出口公网 IP 加入白名单后,点击「重试」。',
cooldownSeconds: seconds,
};
}
return { error: raw, hint: '', cooldownSeconds: 0 };
}
function sendError(res, error, fallback = '模块化库存监控请求失败') {
const status = Number(error?.statusCode) || 500;
const { error: message, hint, cooldownSeconds } = describeError(error, fallback);
console.error('[ModularStockMonitoring]', {
message: error?.message || fallback,
code: error?.code || null,
runtime: error?.cloudDbRuntime || null,
cooldownSeconds,
status,
stack: error?.stack || null,
});
res.status(status).json({ success: false, error: message, hint, cooldownSeconds });
}
// 列表:默认只取「简尚-崔桥工厂品牌仓」仓库下的 SKU
router.get('/list', async (req, res) => {
try {
res.json({ success: true, data: await getModularStockList(req.query || {}) });
} catch (error) {
sendError(res, error, '加载模块化库存监控列表失败');
}
});
// 仓库下拉选项(用于校验业务库里的实际仓库名)
router.get('/warehouses', async (_req, res) => {
try {
res.json({ success: true, data: await getWarehouseOptions() });
} catch (error) {
sendError(res, error, '加载仓库选项失败');
}
});
router.get('/default-warehouse', (_req, res) => {
res.json({ success: true, data: { warehouse: DEFAULT_WAREHOUSE } });
});
module.exports = router;

View File

@ -34,6 +34,7 @@ const marketingEventsRoutes = require('./api/marketingEvents');
const promotionModuleRoutes = require('./api/promotionModule');
const productMonitoringRoutes = require('./api/productMonitoring');
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
const modularStockMonitoringRoutes = require('./api/modularStockMonitoring');
const mkhBomRoutes = require('./api/mkhBom');
const dailyInventoryRoutes = require('./api/dailyInventory');
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
@ -662,6 +663,7 @@ async function startServer(options = {}) {
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
cubejsApp.use('/api/modular-stock-monitoring', authMiddleware, modularStockMonitoringRoutes);
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);

View File

@ -0,0 +1,158 @@
'use strict';
// 聚水潭开放平台(openapi.jushuitan.com)通用客户端。
//
// 用途:拉取业务库 replica 里没有同步的数据,例如「加工单」(/open/jushuitan/manufacture/query)。
//
// 凭证通过环境变量注入(不要写进代码):
// JST_APP_KEY / JST_APP_SECRET / JST_ACCESS_TOKEN
// JST_API_BASE_URL 默认 https://openapi.jushuitan.com
// JST_SIGN_VARIANT 签名变体,默认 pre-lower;可用 scripts/jst-manufacture-probe.js 探测
// JST_API_TIMEOUT_MS 默认 60000
//
// 签名规则(各资料说法不一,故支持 4 种变体,用探测脚本确定实际有效的一种):
// 取所有参数(排除 sign)按 key 升序拼接成 key1value1key2value2...
// pre-* : md5(secret + concat + secret)
// post-* : md5(concat + secret)
// 后缀 -upper 表示取大写十六进制,默认小写
const crypto = require('crypto');
const axios = require('axios');
const DEFAULT_BASE_URL = 'https://openapi.jushuitan.com';
const DEFAULT_TIMEOUT_MS = 60_000;
const SIGN_VARIANTS = Object.freeze(['pre-lower', 'pre-upper', 'post-lower', 'post-upper']);
class JushuitanApiError extends Error {
constructor(message, options = {}) {
super(message);
this.name = 'JushuitanApiError';
this.details = options.details || null;
this.status = options.status || null;
}
}
function readConfig() {
const baseUrl = String(process.env.JST_API_BASE_URL || DEFAULT_BASE_URL).trim().replace(/\/+$/, '');
const timeoutRaw = Number.parseInt(process.env.JST_API_TIMEOUT_MS || '', 10);
return {
baseUrl: baseUrl || DEFAULT_BASE_URL,
appKey: String(process.env.JST_APP_KEY || '').trim(),
appSecret: String(process.env.JST_APP_SECRET || '').trim(),
accessToken: String(process.env.JST_ACCESS_TOKEN || '').trim(),
signVariant: String(process.env.JST_SIGN_VARIANT || 'pre-lower').trim() || 'pre-lower',
timeoutMs: Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS,
};
}
function isConfigured() {
const cfg = readConfig();
return Boolean(cfg.appKey && cfg.appSecret && cfg.accessToken);
}
function describeConfig() {
const cfg = readConfig();
return {
baseUrl: cfg.baseUrl,
signVariant: cfg.signVariant,
appKey: cfg.appKey ? `${cfg.appKey.slice(0, 4)}***${cfg.appKey.slice(-2)}` : '(未配置)',
appSecret: cfg.appSecret ? '(已配置)' : '(未配置)',
accessToken: cfg.accessToken ? '(已配置)' : '(未配置)',
configured: isConfigured(),
};
}
function buildSignString(params, secret, variant) {
const keys = Object.keys(params)
.filter((key) => key !== 'sign')
.sort();
const concat = keys
.map((key) => `${key}${params[key] == null ? '' : params[key]}`)
.join('');
const source = variant.startsWith('pre') ? `${secret}${concat}${secret}` : `${concat}${secret}`;
const digest = crypto.createHash('md5').update(source, 'utf8').digest('hex');
return variant.endsWith('upper') ? digest.toUpperCase() : digest;
}
function buildPublicParams(cfg) {
return {
app_key: cfg.appKey,
access_token: cfg.accessToken,
timestamp: String(Math.floor(Date.now() / 1000)),
charset: 'utf-8',
version: '2',
};
}
function extractApiError(payload) {
if (!payload || typeof payload !== 'object') return null;
const code = payload.code;
const okByCode = code === undefined || code === null || String(code) === '0';
const okByFlag = payload.issuccess === undefined ? true : Boolean(payload.issuccess);
if (okByCode && okByFlag) return null;
return `code=${code} msg=${payload.msg || payload.message || ''}`.trim();
}
async function requestWithVariant(cfg, path, bizParams, variant) {
const params = { ...buildPublicParams(cfg), ...bizParams };
params.sign = buildSignString(params, cfg.appSecret, variant);
const url = `${cfg.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const response = await axios.post(url, params, {
timeout: cfg.timeoutMs,
headers: { 'Content-Type': 'application/json;charset=utf-8' },
validateStatus: null,
transformRequest: [(data) => JSON.stringify(data)],
});
return { status: response.status, payload: response.data };
}
// 用配置里的签名变体调用接口;失败时抛出带响应内容的错误
async function callApi(path, bizParams = {}) {
const cfg = readConfig();
if (!isConfigured()) {
throw new JushuitanApiError(
'聚水潭开放平台凭证未配置:请在 backend/.env 里补 JST_APP_KEY / JST_APP_SECRET / JST_ACCESS_TOKEN'
);
}
const { status, payload } = await requestWithVariant(cfg, path, bizParams, cfg.signVariant);
const apiError = extractApiError(payload);
if (apiError) {
throw new JushuitanApiError(`聚水潭接口调用失败(${path}): ${apiError}`, { status, details: payload });
}
return payload;
}
// 依次尝试 4 种签名变体,返回第一个成功的;用于初次接入时探测签名规则
async function probeSignVariants(path, bizParams = {}) {
const cfg = readConfig();
if (!isConfigured()) {
throw new JushuitanApiError(
'聚水潭开放平台凭证未配置:请在 backend/.env 里补 JST_APP_KEY / JST_APP_SECRET / JST_ACCESS_TOKEN'
);
}
const attempts = [];
for (const variant of SIGN_VARIANTS) {
try {
const { status, payload } = await requestWithVariant(cfg, path, bizParams, variant);
const apiError = extractApiError(payload);
attempts.push({ variant, status, ok: !apiError, error: apiError || null });
if (!apiError) {
return { variant, status, payload, attempts };
}
} catch (error) {
attempts.push({ variant, status: null, ok: false, error: error.message });
}
}
return { variant: null, status: null, payload: null, attempts };
}
module.exports = {
JushuitanApiError,
SIGN_VARIANTS,
readConfig,
describeConfig,
isConfigured,
buildSignString,
callApi,
probeSignVariants,
};

View File

@ -0,0 +1,418 @@
'use strict';
// 模块化库存监控
//
// 数据源与「商品监控」同源:业务库 dbo.pm_stockMonitor(库存宽表)+ dbo.jst_sku(颜色/尺寸/图片)
// 差异:本模块只取指定仓库(默认「简尚-崔桥工厂品牌仓」)下的 SKU。
//
// 表头 → 源字段映射(依据 productMonitoringService 头部注释,2026-09-11 实测确认):
// 商品编码 ← m.sku
// 颜色及规格 ← s.color + s.properties_value
// 7天日均销量 ← m.sevenAvgSale
// 实际当日库存数 ← COALESCE(jst_inventoryInfo.qty, m.jsStock) 优先实时,回退快照
// 库存可用数天 ← m.jsUseDays + (实时库存 - 快照库存) / 7天日均销量 增量修正
// 采购在途数 ← m.purchase (⚠️ 语义未确认,原表注释为「采购负责人」,此处按数值近似)
// 总待发数量 ← m.outOfStock (⚠️ 语义未确认,按数值近似;模块.xlsx 样本中该列为数字)
// 总可用天数 ← (实际库存 + 采购在途) / 7天日均销量
// 预警 ← 依据实际库存、可用天数与安全天数推算
//
// ⚠️ 采购在途数 / 今日总待发数量 为近似取值,业务确认字段语义后需替换为正式字段。
const cloudDatabase = require('../config/cloudDatabase');
const SOURCE_TABLE = 'dbo.pm_stockMonitor';
const SKU_TABLE = 'dbo.jst_sku';
// 聚水潭「库存信息」表:按 (sku_id, wms_co_id) 存放实时库存,本模块用它覆盖日报快照的 jsStock
const INVENTORY_TABLE = 'dbo.jst_inventoryInfo';
const DEFAULT_WAREHOUSE = '简尚-崔桥工厂品牌仓';
// 精确匹配不到时的兜底关键字(表格里仓库列只写了「崔桥工厂品牌仓」,业务库可能带公司前缀)
const WAREHOUSE_FALLBACK_KEYWORD = '崔桥工厂品牌仓';
// 业务仓库名 → 聚水潭 wms_co_id 映射。
// pm_stockMonitor 的 jsStock 是「按天生成」的快照(date 只有当天一个值,由外部报表系统写入,
// 本仓库内没有任何写入逻辑),当天补货后并不会更新 → 会出现「ERP 有货、监控页仍报缺货」。
// 因此这里改为:优先取 jst_inventoryInfo 的实时 qty,取不到才回退快照的 jsStock。
//
// 「简尚-崔桥工厂品牌仓」→ 15870495 的判定依据(2026-09-19 实测):
// 1) 该 wms_co_id 有货 SKU 数 255 ≈ pm 快照在库款数 253
// 2) 抽样 10 个 SKU,jst_inventoryInfo.qty 与 pm.jsStock 逐一吻合(±1~2,属快照时点差异)
// 3) 该仓未收录进 jst_warehouseInfo 字典表(字典里最接近的是「简尚-崔桥工厂仓」15038426,但款数对不上)
const WAREHOUSE_WMS_CO_ID = Object.freeze({
'简尚-崔桥工厂品牌仓': 15870495,
});
// 实时库存覆盖开关(如需回到纯快照口径,把这里改成 false 即可)
const USE_REALTIME_STOCK = true;
function resolveWmsCoId(warehouse) {
if (!USE_REALTIME_STOCK) return -1;
return WAREHOUSE_WMS_CO_ID[String(warehouse || '').trim()] || -1;
}
// 实时库存子查询(OUTER APPLY)。@wmsCoId 传 -1 时不匹配任何行 → inv.qty 为 NULL → 自动回退快照值
const REALTIME_STOCK_APPLY = `
OUTER APPLY (
SELECT TOP 1 i.qty AS qty
FROM ${INVENTORY_TABLE} i
WHERE i.sku_id = m.sku_id
AND i.wms_co_id = @wmsCoId
) inv`;
// 实际库存:优先实时,取不到回退快照
const ACTUAL_STOCK_EXPR = `COALESCE(inv.qty, m.jsStock)`;
// 库存可用天数:随库存变化做「增量修正」而不是重新定义公式。
// 原因:pm_stockMonitor.jsUseDays 是外部报表按自有口径算好的(实测与 (库存-待发)/日均 的
// 吻合率仅 14%,说明它还含其它规则),直接改成比例公式会误伤大量低销量商品
// (实测「低于安全库存」会从 108 掉到 62)。这里只在拿到实时库存时,按
// (实时库存 - 快照库存) / 7天日均销量
// 做增量调整——库存没变的 SKU 结果与 ERP 完全一致,只有真正补货/出库的 SKU 才随之变化。
const AVAILABLE_DAYS_EXPR = `
CASE
WHEN inv.qty IS NOT NULL
AND m.sevenAvgSale IS NOT NULL AND m.sevenAvgSale > 0
THEN ISNULL(m.jsUseDays, 0) + (inv.qty - ISNULL(m.jsStock, 0)) * 1.0 / m.sevenAvgSale
ELSE m.jsUseDays
END`;
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 200;
const QUERY_TIMEOUT_MS = 180000;
const ALERT_LOW_STOCK_DAYS = 7;
function toNumberOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function roundTo2(value) {
if (value === null) return null;
return Math.round(value * 100) / 100;
}
function formatStatDate(value) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
return String(value).slice(0, 10);
}
async function getLatestStatDate() {
const rows = await cloudDatabase.query(
`SELECT MAX(date) AS latestDate FROM ${SOURCE_TABLE}`,
{},
{ requestTimeout: QUERY_TIMEOUT_MS }
);
const latest = rows?.[0]?.latestDate;
if (!latest) {
throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期');
}
return latest;
}
function buildWarehouseFilter(mode, warehouse) {
if (mode === 'fuzzy') {
return {
sql: 'AND m.warehouse LIKE @warehouseLike',
params: { warehouseLike: `%${WAREHOUSE_FALLBACK_KEYWORD}%` },
};
}
return {
sql: 'AND m.warehouse = @warehouse',
params: { warehouse },
};
}
function buildKeywordFilter(keyword) {
if (!keyword) return { sql: '', params: {} };
return {
sql: 'AND (m.sku LIKE @keyword OR m.skuName LIKE @keyword OR s.sku_code LIKE @keyword)',
params: { keyword: `%${keyword}%` },
};
}
const ROW_COLUMNS = `
m.sku_id AS skuId,
m.sku AS productCode,
m.skuName AS productName,
m.spu AS spu,
m.warehouse AS warehouse,
m.sevenAvgSale AS avgSales7d,
m.jsStock AS snapshotStock,
${ACTUAL_STOCK_EXPR} AS actualStock,
${AVAILABLE_DAYS_EXPR} AS stockAvailableDays,
inv.qty AS realtimeStock,
m.purchase AS purchaseRaw,
m.outOfStock AS outOfStockRaw,
m.safetyDays AS safetyDays,
m.suggestStock AS suggestedReplenish,
s.sku_code AS barcode,
s.color AS colorRaw,
s.properties_value AS specRaw,
COALESCE(s.pic_big, s.pic) AS imageUrl`;
// 预警分档的 SQL 判定表达式(与 resolveAlert() 逻辑严格一致,顺序不可调换)
// noShipment 无历史发货:7天日均销量 <= 0 或为空
// outOfStock 缺货:有销量且实际库存 <= 0
// belowSafety 低于安全库存:库存可用天数 < 安全天数
// lowStock 库存偏低:库存可用天数 < 7
// normal 正常
const ALERT_TYPE_SQL = `
CASE
WHEN m.sevenAvgSale IS NULL OR m.sevenAvgSale <= 0 THEN 'noShipment'
WHEN ${ACTUAL_STOCK_EXPR} IS NOT NULL AND ${ACTUAL_STOCK_EXPR} <= 0 THEN 'outOfStock'
WHEN (${AVAILABLE_DAYS_EXPR}) IS NOT NULL AND m.safetyDays IS NOT NULL
AND (${AVAILABLE_DAYS_EXPR}) < m.safetyDays THEN 'belowSafety'
WHEN (${AVAILABLE_DAYS_EXPR}) IS NOT NULL AND (${AVAILABLE_DAYS_EXPR}) < ${ALERT_LOW_STOCK_DAYS} THEN 'lowStock'
ELSE 'normal'
END`;
// 全量聚合统计(与分页无关,供页面顶部统计卡使用)
// total SKU 总数(款)
// inStockSkuCount 在库 SKU 款数(实际库存 > 0)
// totalStock 现有库存总数(实际库存求和)
// outOfStockCount 缺货款数
// belowSafetyCount 低于安全库存款数
// lowStockCount 库存偏低款数
// noShipmentCount 无历史发货款数
// normalCount 正常款数
async function aggregateRows({ statDate, mode, warehouse, keyword, wmsCoId }) {
const wf = buildWarehouseFilter(mode, warehouse);
const kf = buildKeywordFilter(keyword);
const rows = await cloudDatabase.query(
`
SELECT
COUNT(1) AS total,
SUM(CASE WHEN t.jsStock IS NOT NULL AND t.jsStock > 0 THEN 1 ELSE 0 END) AS inStockSkuCount,
SUM(CASE WHEN t.jsStock IS NOT NULL AND t.jsStock > 0 THEN t.jsStock ELSE 0 END) AS totalStock,
SUM(CASE WHEN t.alertType = 'outOfStock' THEN 1 ELSE 0 END) AS outOfStockCount,
SUM(CASE WHEN t.alertType = 'belowSafety' THEN 1 ELSE 0 END) AS belowSafetyCount,
SUM(CASE WHEN t.alertType = 'lowStock' THEN 1 ELSE 0 END) AS lowStockCount,
SUM(CASE WHEN t.alertType = 'noShipment' THEN 1 ELSE 0 END) AS noShipmentCount,
SUM(CASE WHEN t.alertType = 'normal' THEN 1 ELSE 0 END) AS normalCount,
MAX(t.hasRealtime) AS hasRealtimeRow
FROM (
SELECT
${ACTUAL_STOCK_EXPR} AS jsStock,
CASE WHEN inv.qty IS NULL THEN 0 ELSE 1 END AS hasRealtime,
${ALERT_TYPE_SQL} AS alertType
FROM ${SOURCE_TABLE} m
LEFT JOIN ${SKU_TABLE} s ON s.sku_id = m.sku_id
${REALTIME_STOCK_APPLY}
WHERE m.date = @statDate
${wf.sql}
${kf.sql}
) t
`,
{ statDate, wmsCoId, ...wf.params, ...kf.params },
{ requestTimeout: QUERY_TIMEOUT_MS }
);
const row = rows?.[0] || {};
return {
total: Number(row.total || 0),
inStockSkuCount: Number(row.inStockSkuCount || 0),
totalStock: Number(row.totalStock || 0),
outOfStockCount: Number(row.outOfStockCount || 0),
belowSafetyCount: Number(row.belowSafetyCount || 0),
lowStockCount: Number(row.lowStockCount || 0),
noShipmentCount: Number(row.noShipmentCount || 0),
normalCount: Number(row.normalCount || 0),
hasRealtimeRow: Number(row.hasRealtimeRow || 0) > 0,
};
}
async function queryRows({ statDate, mode, warehouse, keyword, page, pageSize, wmsCoId }) {
const wf = buildWarehouseFilter(mode, warehouse);
const kf = buildKeywordFilter(keyword);
const rows = await cloudDatabase.query(
`
SELECT ${ROW_COLUMNS}
FROM ${SOURCE_TABLE} m
LEFT JOIN ${SKU_TABLE} s ON s.sku_id = m.sku_id
${REALTIME_STOCK_APPLY}
WHERE m.date = @statDate
${wf.sql}
${kf.sql}
ORDER BY
CASE
-- 缺货:有历史发货且实际库存 <= 0,置顶
WHEN m.sevenAvgSale IS NOT NULL AND m.sevenAvgSale > 0
AND ${ACTUAL_STOCK_EXPR} IS NOT NULL AND ${ACTUAL_STOCK_EXPR} <= 0 THEN 0
-- 低于安全库存
WHEN m.sevenAvgSale IS NOT NULL AND m.sevenAvgSale > 0
AND (${AVAILABLE_DAYS_EXPR}) IS NOT NULL AND m.safetyDays IS NOT NULL
AND (${AVAILABLE_DAYS_EXPR}) < m.safetyDays THEN 1
-- 库存偏低
WHEN m.sevenAvgSale IS NOT NULL AND m.sevenAvgSale > 0
AND (${AVAILABLE_DAYS_EXPR}) IS NOT NULL AND (${AVAILABLE_DAYS_EXPR}) < ${ALERT_LOW_STOCK_DAYS} THEN 2
-- 正常 / 无历史发货
ELSE 3
END ASC,
m.sku ASC
OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY
`,
{
statDate,
wmsCoId,
...wf.params,
...kf.params,
offset: (page - 1) * pageSize,
pageSize,
},
{ requestTimeout: QUERY_TIMEOUT_MS }
);
return rows || [];
}
function resolveAlert({ actualStock, stockAvailableDays, safetyDays, avgSales7d }) {
// 历史发货数据为 0(无发货记录)的 SKU 标绿、不做缺货预警;
// 等后期有发货(7天日均销量 > 0)后再按正常规则触发预警。
if (avgSales7d === null || avgSales7d <= 0) {
return { level: 'normal', label: '无历史发货' };
}
if (actualStock !== null && actualStock <= 0) {
return { level: 'danger', label: '缺货' };
}
if (
stockAvailableDays !== null
&& safetyDays !== null
&& stockAvailableDays < safetyDays
) {
return { level: 'danger', label: '低于安全库存' };
}
if (stockAvailableDays !== null && stockAvailableDays < ALERT_LOW_STOCK_DAYS) {
return { level: 'warning', label: '库存偏低' };
}
return { level: 'normal', label: '正常' };
}
function calcTotalAvailableDays({ actualStock, purchaseInTransit, avgSales7d }) {
if (avgSales7d === null || avgSales7d <= 0) return null;
const total = (actualStock || 0) + (purchaseInTransit || 0);
return roundTo2(total / avgSales7d);
}
function mapRow(row) {
const avgSales7d = toNumberOrNull(row.avgSales7d);
const actualStock = toNumberOrNull(row.actualStock);
const stockAvailableDays = roundTo2(toNumberOrNull(row.stockAvailableDays));
const safetyDays = toNumberOrNull(row.safetyDays);
const purchaseInTransit = toNumberOrNull(row.purchaseRaw);
const pendingShipmentToday = toNumberOrNull(row.outOfStockRaw);
const color = row.colorRaw || '';
const spec = row.specRaw || '';
return {
skuId: row.skuId,
productCode: row.productCode || row.skuId,
barcode: row.barcode || null,
productName: row.productName || null,
spu: row.spu || null,
warehouse: row.warehouse || null,
imageUrl: row.imageUrl || null,
colorSpec: [color, spec].filter(Boolean).join(' / ') || null,
avgSales7d,
actualStock,
// 报表快照库存(pm_stockMonitor.jsStock)与聚水潭实时库存,便于前端区分/核查
snapshotStock: toNumberOrNull(row.snapshotStock),
realtimeStock: toNumberOrNull(row.realtimeStock),
stockAvailableDays,
purchaseInTransit,
pendingShipmentToday,
totalAvailableDays: calcTotalAvailableDays({ actualStock, purchaseInTransit, avgSales7d }),
safetyDays,
suggestedReplenish: toNumberOrNull(row.suggestedReplenish),
alert: resolveAlert({ actualStock, stockAvailableDays, safetyDays, avgSales7d }),
};
}
async function getModularStockList(options = {}) {
const page = Math.max(1, Number(options.page) || 1);
const pageSize = Math.min(
MAX_PAGE_SIZE,
Math.max(1, Number(options.pageSize) || DEFAULT_PAGE_SIZE)
);
const keyword = String(options.keyword || '').trim();
const explicitWarehouse = String(options.warehouse || '').trim();
const requestedWarehouse = explicitWarehouse || DEFAULT_WAREHOUSE;
const statDate = await getLatestStatDate();
const wmsCoId = resolveWmsCoId(requestedWarehouse);
let matchMode = 'exact';
let stats = await aggregateRows({ statDate, mode: matchMode, warehouse: requestedWarehouse, keyword, wmsCoId });
// 调用方没显式指定仓库、且精确匹配不到时,退化为模糊匹配,避免仓库名前缀差异导致空列表
if (stats.total === 0 && !explicitWarehouse) {
matchMode = 'fuzzy';
stats = await aggregateRows({ statDate, mode: matchMode, warehouse: requestedWarehouse, keyword, wmsCoId });
}
const {
total,
inStockSkuCount,
totalStock,
outOfStockCount,
belowSafetyCount,
lowStockCount,
noShipmentCount,
normalCount,
} = stats;
const rows = total > 0
? await queryRows({ statDate, mode: matchMode, warehouse: requestedWarehouse, keyword, page, pageSize, wmsCoId })
: [];
return {
statDate: formatStatDate(statDate),
warehouse: requestedWarehouse,
matchMode,
// 库存数据来源:realtime=聚水潭实时库存;snapshot=报表快照(jsStock)
stockSource: wmsCoId > 0 ? 'realtime' : 'snapshot',
wmsCoId: wmsCoId > 0 ? wmsCoId : null,
realtimeMatched: Boolean(stats.hasRealtimeRow),
page,
pageSize,
total,
inStockSkuCount,
totalStock,
outOfStockCount,
belowSafetyCount,
lowStockCount,
noShipmentCount,
normalCount,
rows: rows.map(mapRow),
};
}
async function getWarehouseOptions() {
const statDate = await getLatestStatDate();
const rows = await cloudDatabase.query(
`
SELECT m.warehouse AS warehouse, COUNT(1) AS skuCount
FROM ${SOURCE_TABLE} m
WHERE m.date = @statDate
GROUP BY m.warehouse
ORDER BY m.warehouse
`,
{ statDate },
{ requestTimeout: QUERY_TIMEOUT_MS }
);
return {
statDate: formatStatDate(statDate),
warehouses: (rows || [])
.filter((row) => row.warehouse)
.map((row) => ({
warehouse: row.warehouse,
skuCount: Number(row.skuCount || 0),
})),
};
}
module.exports = {
DEFAULT_WAREHOUSE,
getModularStockList,
getWarehouseOptions,
};

View File

@ -0,0 +1,16 @@
import request from './request';
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
const META_TIMEOUT_MS = 60 * 1000;
// 列表(默认只取「简尚-崔桥工厂品牌仓」仓库下的 SKU)
export const getModularStockList = (params = {}) => request.get(
'/api/modular-stock-monitoring/list',
{ params, timeout: LIST_TIMEOUT_MS }
);
// 仓库下拉选项(用于核对业务库里的实际仓库名)
export const getModularStockWarehouses = () => request.get(
'/api/modular-stock-monitoring/warehouses',
{ timeout: META_TIMEOUT_MS }
);

View File

@ -34,6 +34,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
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: 'modular-stock-monitoring', label: '全仓库存监控', icon: 'database', routeKey: 'modular-stock-monitoring', visible: true, order: 30 },
],
},
{

View File

@ -108,6 +108,11 @@ const SIDEBAR_ROUTE_MAP = {
},
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
'product-analysis': { path: '/planning/operation/product-analysis' },
'modular-stock-monitoring': {
path: '/planning/operation/modular-stock-monitoring',
activePath: '/planning/operation/modular-stock-monitoring',
matchMode: 'prefix',
},
'work-orders': { path: '/planning/operation/work-orders' },
'modular-stock-monitor-accessory': {
path: '/planning/operation/modular-stock-monitor',

View File

@ -0,0 +1,346 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert, Button, Card, Col, Dropdown, Image, Input, Row, Select,
Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { SycmPage } from '../../components/Common';
import {
getModularStockList,
getModularStockWarehouses,
} from '../../api/modularStockMonitoring';
import { exportTableFile } from '../../utils/tableExport';
import './style.css';
const { Title, Text } = Typography;
const DEFAULT_WAREHOUSE = '简尚-崔桥工厂品牌仓';
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 ALERT_COLOR = { danger: 'red', warning: 'orange', normal: 'green' };
const ModularStockMonitoringPanel = () => {
const [warehouse, setWarehouse] = useState('');
const [keyword, setKeyword] = useState('');
const [warehouseOptions, setWarehouseOptions] = useState([]);
const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, statDate: null, matchMode: 'exact', stockSource: null });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [errorHint, setErrorHint] = useState('');
const [cooldownRemaining, setCooldownRemaining] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
useEffect(() => {
let cancelled = false;
getModularStockWarehouses()
.then((result) => {
const payload = result?.data || {};
if (cancelled) return;
setWarehouseOptions((payload.warehouses || [])
.map((item) => ({
value: item.warehouse,
label: `${item.warehouse}(${item.skuCount})`,
})));
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
const load = useCallback(async () => {
setLoading(true);
setError('');
setErrorHint('');
try {
const result = await getModularStockList({
page,
pageSize,
warehouse,
keyword,
});
setData(result?.data || { rows: [], total: 0, page, pageSize, statDate: null, matchMode: 'exact' });
} catch (requestError) {
const payload = requestError?.response?.data || {};
setError(payload.error || requestError?.message || '加载模块化库存监控数据失败');
setErrorHint(payload.hint || '');
const cooldownSec = Number(payload.cooldownSeconds || 0);
setCooldownRemaining(cooldownSec > 0 ? cooldownSec : 0);
} finally {
setLoading(false);
}
}, [page, pageSize, warehouse, keyword]);
useEffect(() => { load(); }, [load]);
// 倒计时到 0 时允许用户重新尝试,避免对同一个不可达的地址连点刷新
useEffect(() => {
if (cooldownRemaining <= 0) return undefined;
const tick = setInterval(() => {
setCooldownRemaining((prev) => (prev > 0 ? prev - 1 : 0));
}, 1000);
return () => clearInterval(tick);
}, [cooldownRemaining]);
const retryLabel = cooldownRemaining > 0 ? `${cooldownRemaining}s 后可重试` : '重试';
const retryDisabled = cooldownRemaining > 0;
const changeWarehouse = (value) => {
setWarehouse(value || '');
setPage(1);
};
const changeKeyword = (value) => {
setKeyword(value || '');
setPage(1);
};
const columns = useMemo(() => [
{
title: '预警',
dataIndex: ['alert', 'label'],
width: 110,
fixed: 'left',
render: (value, row) => (
<Tag color={ALERT_COLOR[row.alert?.level] || 'default'}>{text(value)}</Tag>
),
},
{
title: '图片',
dataIndex: 'imageUrl',
width: 64,
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: 'productCode',
width: 190,
fixed: 'left',
ellipsis: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{
title: '颜色及规格',
dataIndex: 'colorSpec',
width: 200,
ellipsis: true,
render: (value) => <Tooltip title={value}>{text(value)}</Tooltip>,
},
{ title: '7天日均销量', dataIndex: 'avgSales7d', width: 120, align: 'right', render: (v) => number(v) },
{ title: '实际当日库存数', dataIndex: 'actualStock', width: 130, align: 'right', render: int },
{ title: '库存可用数天', dataIndex: 'stockAvailableDays', width: 120, align: 'right', render: (v) => number(v, 1) },
{ title: '采购在途数', dataIndex: 'purchaseInTransit', width: 110, align: 'right', render: int },
{ title: '总待发数量', dataIndex: 'pendingShipmentToday', width: 130, align: 'right', render: int },
{ title: '总可用天数', dataIndex: 'totalAvailableDays', width: 120, align: 'right', render: (v) => number(v, 1) },
{ title: '仓库', dataIndex: 'warehouse', width: 200, ellipsis: true, render: (v) => <Tooltip title={v}>{text(v)}</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.warehouse || DEFAULT_WAREHOUSE}_${data.statDate || '最新'}`,
exportColumns,
data.rows,
'模块化库存监控'
);
message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`);
} catch (exportError) {
message.error(exportError?.message || '导出失败');
}
};
const rows = data.rows || [];
// 所有统计卡统一走「仓库全量」口径,均由后端一次聚合返回(不受分页影响)
const summary = useMemo(() => ({
skuCount: data.total || 0,
inStockSkuCount: data.inStockSkuCount || 0,
totalStock: data.totalStock || 0,
outOfStockCount: data.outOfStockCount || 0,
belowSafetyCount: data.belowSafetyCount || 0,
lowStockCount: data.lowStockCount || 0,
noShipmentCount: data.noShipmentCount || 0,
}), [
data.total,
data.inStockSkuCount,
data.totalStock,
data.outOfStockCount,
data.belowSafetyCount,
data.lowStockCount,
data.noShipmentCount,
]);
return (
<>
<Card bordered={false} className="msm-filter-card">
<Space wrap size={[12, 12]}>
<Select
allowClear
showSearch
placeholder={`仓库(默认 ${DEFAULT_WAREHOUSE})`}
options={warehouseOptions}
value={warehouse || undefined}
onChange={changeWarehouse}
style={{ width: 320 }}
/>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="商品编码 / 条码 / 产品名称"
value={keyword}
onChange={(e) => changeKeyword(e.target.value)}
style={{ width: 260 }}
/>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
</Space>
</Card>
{error ? (
<Alert
type="error"
showIcon
message={error}
description={errorHint || undefined}
action={(
<Button size="small" disabled={retryDisabled} onClick={load}>
{retryLabel}
</Button>
)}
/>
) : null}
{data.matchMode === 'fuzzy' && !error ? (
<Alert
type="warning"
showIcon
message={`未精确匹配到「${DEFAULT_WAREHOUSE}」,已按关键字「崔桥工厂品牌仓」模糊匹配;请从上方仓库下拉中选择实际仓库名`}
/>
) : null}
<Row gutter={[12, 12]} className="msm-summary-row" wrap>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card">
<Statistic title="SKU 总数(仓库全量)" value={summary.skuCount} />
</Card>
</Col>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card is-cyan">
<Statistic title="在库 SKU(仓库全量)" value={summary.inStockSkuCount} />
</Card>
</Col>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card is-red">
<Statistic
title="缺货(仓库全量)"
value={summary.outOfStockCount}
valueStyle={{ color: 'var(--sycm-color-danger)' }}
/>
</Card>
</Col>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card is-purple">
<Statistic title="低于安全库存(仓库全量)" value={summary.belowSafetyCount} />
</Card>
</Col>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card is-orange">
<Statistic title="库存偏低(仓库全量)" value={summary.lowStockCount} />
</Card>
</Col>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card is-green">
<Statistic title="无历史发货(仓库全量)" value={summary.noShipmentCount} />
</Card>
</Col>
<Col flex="1 1 190px">
<Card bordered={false} className="msm-summary-card">
<Statistic title="现有库存总数(仓库全量)" value={summary.totalStock} />
</Card>
</Col>
</Row>
<Card
bordered={false}
className="msm-panel"
title={(
<Space size={12} wrap>
<span>库存明细({data.total || 0} 条)</span>
<Text type="secondary">
仓库 {data.warehouse || DEFAULT_WAREHOUSE}
</Text>
{data.statDate ? (
<Text type="secondary">
统计日 {data.statDate}
{data.stockSource === 'realtime' ? ' · 实际库存为实时' : ''}
</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="正在加载库存监控数据…">
<Table
rowKey="skuId"
size="small"
columns={columns}
dataSource={rows}
onChange={(pagination) => {
setPage(pagination.current || 1);
setPageSize(pagination.pageSize || 50);
}}
pagination={{
current: data.page || page,
pageSize: data.pageSize || pageSize,
total: data.total || 0,
showSizeChanger: true,
pageSizeOptions: [50, 100, 200],
showTotal: (total) => `共 ${total} 条`,
}}
scroll={{ x: 1700, y: 'min(520px, calc(100vh - 520px))' }}
locale={{ emptyText: '该仓库下暂无库存数据' }}
/>
</Spin>
</Card>
</>
);
};
const ModularStockMonitoring = () => (
<SycmPage className="msm-page">
<header className="msm-header">
<div className="msm-header__heading">
<Title level={3}>模块化库存监控</Title>
<Text type="secondary">
按仓库查看 SKU 库存与销量的模块化视图,默认只取「{DEFAULT_WAREHOUSE}」仓库下的商品编码
</Text>
</div>
</header>
<ModularStockMonitoringPanel />
</SycmPage>
);
export default ModularStockMonitoring;

View File

@ -0,0 +1,120 @@
.msm-page {
container-name: modular-stock-monitoring;
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-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-purple::before { background: var(--sycm-color-purple, #722ed1); }
.msm-summary-card.is-cyan::before { background: var(--sycm-color-cyan, #13c2c2); }
.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-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-muted { color: var(--sycm-text-tertiary); }
@container modular-stock-monitoring (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

@ -18,6 +18,7 @@ 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 ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
@ -109,6 +110,10 @@ const router = createBrowserRouter([
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),