feat: 新增模块化库存监控(全仓口径 + 实时库存) #39
163
backend/scripts/jst-manufacture-probe.js
Normal file
163
backend/scripts/jst-manufacture-probe.js
Normal 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;
|
||||
});
|
||||
106
backend/src/api/modularStockMonitoring.js
Normal file
106
backend/src/api/modularStockMonitoring.js
Normal 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;
|
||||
@ -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);
|
||||
|
||||
158
backend/src/services/jushuitanClient.js
Normal file
158
backend/src/services/jushuitanClient.js
Normal 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,
|
||||
};
|
||||
418
backend/src/services/modularStockMonitoringService.js
Normal file
418
backend/src/services/modularStockMonitoringService.js
Normal 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,
|
||||
};
|
||||
16
frontend/src/api/modularStockMonitoring.js
Normal file
16
frontend/src/api/modularStockMonitoring.js
Normal 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 }
|
||||
);
|
||||
@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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',
|
||||
|
||||
346
frontend/src/pages/ModularStockMonitoring/index.jsx
Normal file
346
frontend/src/pages/ModularStockMonitoring/index.jsx
Normal 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;
|
||||
120
frontend/src/pages/ModularStockMonitoring/style.css
Normal file
120
frontend/src/pages/ModularStockMonitoring/style.css
Normal 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; }
|
||||
}
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user