From 51b9f9eeedd2547eb7780aa0417ab36a96fec8db Mon Sep 17 00:00:00 2001 From: wangjie Date: Thu, 24 Sep 2026 15:34:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8C=96=E5=BA=93=E5=AD=98=E7=9B=91=E6=8E=A7=EF=BC=88=E5=85=A8?= =?UTF-8?q?=E4=BB=93=E5=8F=A3=E5=BE=84=20+=20=E5=AE=9E=E6=97=B6=E5=BA=93?= =?UTF-8?q?=E5=AD=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增库存监控页面、后端服务与 API,支持分页筛选与 CSV 导出 - 库存取值优先聚水潭实时库存(jst_inventoryInfo),取不到回退日报快照(pm_stockMonitor) - 统计卡与预警分档统一全仓库口径,新增在库 SKU 款数 - 预留聚水潭加工单接入(jushuitanClient + 自检脚本),凭证到位后可算采购在途 --- backend/scripts/jst-manufacture-probe.js | 163 +++++++ backend/src/api/modularStockMonitoring.js | 106 +++++ backend/src/config/cloudDatabase.js | 39 +- backend/src/index.js | 2 + backend/src/services/jushuitanClient.js | 158 +++++++ .../services/modularStockMonitoringService.js | 418 ++++++++++++++++++ frontend/src/api/modularStockMonitoring.js | 16 + frontend/src/config/sidebarNavigation.js | 1 + frontend/src/layouts/BasicLayout.jsx | 1 + .../pages/ModularStockMonitoring/index.jsx | 346 +++++++++++++++ .../pages/ModularStockMonitoring/style.css | 120 +++++ frontend/src/router/index.jsx | 5 + 12 files changed, 1370 insertions(+), 5 deletions(-) create mode 100644 backend/scripts/jst-manufacture-probe.js create mode 100644 backend/src/api/modularStockMonitoring.js create mode 100644 backend/src/services/jushuitanClient.js create mode 100644 backend/src/services/modularStockMonitoringService.js create mode 100644 frontend/src/api/modularStockMonitoring.js create mode 100644 frontend/src/pages/ModularStockMonitoring/index.jsx create mode 100644 frontend/src/pages/ModularStockMonitoring/style.css diff --git a/backend/scripts/jst-manufacture-probe.js b/backend/scripts/jst-manufacture-probe.js new file mode 100644 index 0000000..2fde7cb --- /dev/null +++ b/backend/scripts/jst-manufacture-probe.js @@ -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; +}); diff --git a/backend/src/api/modularStockMonitoring.js b/backend/src/api/modularStockMonitoring.js new file mode 100644 index 0000000..cdd1e5e --- /dev/null +++ b/backend/src/api/modularStockMonitoring.js @@ -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; diff --git a/backend/src/config/cloudDatabase.js b/backend/src/config/cloudDatabase.js index 405346b..5edbe1d 100644 --- a/backend/src/config/cloudDatabase.js +++ b/backend/src/config/cloudDatabase.js @@ -19,6 +19,7 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app'; let pool = null; let activeCloudConfig = null; let cachedCloudConfigCandidates = null; +let lastConnectFailure = null; function isPoolUsable(activePool) { return Boolean(activePool && activePool.connected && activePool.healthy !== false); @@ -35,6 +36,10 @@ const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10); const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000); const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000); const LOCAL_CONFIG_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_CONFIG_DB_CONNECTION_LIMIT, 2); +// 连接建立失败后的冷静期(毫秒)。 +// 若业务库被防火墙「丢包」(而非拒绝),每次连接都要死等到 connectTimeout 才会失败; +// 短时间内重复请求没必要把整套失败流程重跑一遍。 +const CONNECT_FAILURE_COOLDOWN_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_FAILURE_COOLDOWN_MS, 15000); const DEFAULT_ENV_KEYS = Object.freeze({ driver: ['CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'], @@ -689,13 +694,14 @@ async function resolveCloudConfigCandidates() { if (!candidate?.mssqlConfig) { continue; } + // 按真实连接目标去重:public / local 两个标签经常指向同一个 host:port, + // 若按 selectedProfile 去重则视为两个候选,一次请求会把同一个不可达地址串行连接两次。 const dedupeKey = [ - candidate.runtimeInfo.selectedProfile, - candidate.runtimeInfo.source, candidate.runtimeInfo.host, candidate.runtimeInfo.port, candidate.runtimeInfo.database, candidate.runtimeInfo.username, + candidate.mssqlConfig?.password || '', ].join('::'); if (seen.has(dedupeKey)) { continue; @@ -925,11 +931,20 @@ async function createPinnedQueryClient(options = {}) { /** * 获取连接池 */ -const getPool = async () => { +const getPool = async (options = {}) => { if (isPoolUsable(pool)) { return pool; } + if ( + lastConnectFailure + && !options.bypassConnectFailureCache + && Date.now() - lastConnectFailure.at < CONNECT_FAILURE_COOLDOWN_MS + ) { + // 冷静期内直接复用上次的失败结果,避免重复等待连接超时 + throw lastConnectFailure.error; + } + if (pool) { console.warn('[CloudDB] 检测到失效连接池,准备重建连接'); await resetPool(); @@ -948,20 +963,30 @@ const getPool = async () => { + `${candidate.runtimeInfo.host}:${candidate.runtimeInfo.port}/${candidate.runtimeInfo.database}` ); await connectWithCandidate(candidate); + lastConnectFailure = null; console.log('[CloudDB] MSSQL 云数据库连接成功'); return pool; } catch (error) { + // 附带候选配置信息,便于上层把报错翻译成「连不上哪台库」的可读提示 + error.cloudDbRuntime = candidate.runtimeInfo; errors.push(error); console.warn(`[CloudDB] ${candidate.runtimeInfo.selectedProfile} 配置连接失败: ${error.message}`); } } const lastError = errors[errors.length - 1]; + if (lastError) { + // 标记为「连接建立失败」:这类失败重试没有意义,且要计入冷静期 + lastError.cloudDbConnectFailure = true; + lastConnectFailure = { at: Date.now(), error: lastError }; + } console.error('[CloudDB] MSSQL 连接失败:', lastError?.message || NOT_CONFIGURED_MESSAGE); throw lastError || new Error(NOT_CONFIGURED_MESSAGE); }; const resetPool = async () => { + lastConnectFailure = null; + if (!pool) { activeCloudConfig = null; cachedCloudConfigCandidates = null; @@ -989,7 +1014,7 @@ const query = async (sqlText, params = {}, options = {}) => { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { - const activePool = await getPool(); + const activePool = await getPool(options); const resolvedSqlText = rewriteReportReplicaSql(sqlText, activeCloudConfig?.runtimeInfo); const request = activePool.request(); const requestTimeout = parsePositiveInt(options.requestTimeout, 0); @@ -1007,7 +1032,10 @@ const query = async (sqlText, params = {}, options = {}) => { const result = await request.query(resolvedSqlText); return result.recordset; } catch (error) { - const shouldRetry = attempt < maxAttempts && isTransientError(error); + // 连接建立失败不重试:地址不可达时重试只是把同样的超时再跑一遍 + const shouldRetry = attempt < maxAttempts + && !error.cloudDbConnectFailure + && isTransientError(error); if (!shouldRetry) { throw error; } @@ -1081,6 +1109,7 @@ const close = async () => { pool = null; activeCloudConfig = null; cachedCloudConfigCandidates = null; + lastConnectFailure = null; }; /** diff --git a/backend/src/index.js b/backend/src/index.js index 5349b9a..ebac255 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -33,6 +33,7 @@ const productAnalysisRoutes = require('./api/productAnalysis'); const marketingEventsRoutes = require('./api/marketingEvents'); const promotionModuleRoutes = require('./api/promotionModule'); const productMonitoringRoutes = require('./api/productMonitoring'); +const modularStockMonitoringRoutes = require('./api/modularStockMonitoring'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -652,6 +653,7 @@ async function startServer(options = {}) { cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); + cubejsApp.use('/api/modular-stock-monitoring', authMiddleware, modularStockMonitoringRoutes); // ===================== // 数据分析 API(公开访问,可根据需要添加认证) diff --git a/backend/src/services/jushuitanClient.js b/backend/src/services/jushuitanClient.js new file mode 100644 index 0000000..466fada --- /dev/null +++ b/backend/src/services/jushuitanClient.js @@ -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, +}; diff --git a/backend/src/services/modularStockMonitoringService.js b/backend/src/services/modularStockMonitoringService.js new file mode 100644 index 0000000..51ab04e --- /dev/null +++ b/backend/src/services/modularStockMonitoringService.js @@ -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, +}; diff --git a/frontend/src/api/modularStockMonitoring.js b/frontend/src/api/modularStockMonitoring.js new file mode 100644 index 0000000..dc7f423 --- /dev/null +++ b/frontend/src/api/modularStockMonitoring.js @@ -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 } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 78b4cca..08f1230 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -16,6 +16,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, children: [ { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, + { key: 'modular-stock-monitoring', label: '模块化库存监控', icon: 'table', routeKey: 'modular-stock-monitoring', visible: true, order: 30 }, ], }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index f76eecb..da2ed45 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -79,6 +79,7 @@ const SIDEBAR_ROUTE_MAP = { }, 'promotion-module': { path: '/planning/operation/promotion-module' }, 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'modular-stock-monitoring': { path: '/planning/operation/modular-stock-monitoring' }, 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, 'product-analysis': { path: '/planning/operation/product-analysis' }, 'work-orders': { path: '/planning/operation/work-orders' }, diff --git a/frontend/src/pages/ModularStockMonitoring/index.jsx b/frontend/src/pages/ModularStockMonitoring/index.jsx new file mode 100644 index 0000000..4f3fcc8 --- /dev/null +++ b/frontend/src/pages/ModularStockMonitoring/index.jsx @@ -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) => ( + {text(value)} + ), + }, + { + title: '图片', + dataIndex: 'imageUrl', + width: 64, + exportable: false, + render: (value) => (value + ? + : --), + }, + { + title: '商品编码', + dataIndex: 'productCode', + width: 190, + fixed: 'left', + ellipsis: true, + render: (value) => {text(value)}, + }, + { + title: '颜色及规格', + dataIndex: 'colorSpec', + width: 200, + ellipsis: true, + render: (value) => {text(value)}, + }, + { 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) => {text(v)} }, + ], []); + + 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 ( + <> + + + } + placeholder="商品编码 / 条码 / 产品名称" + value={keyword} + onChange={(e) => changeKeyword(e.target.value)} + style={{ width: 260 }} + /> + + + + + {error ? ( + + {retryLabel} + + )} + /> + ) : null} + + {data.matchMode === 'fuzzy' && !error ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 库存明细({data.total || 0} 条) + + 仓库 {data.warehouse || DEFAULT_WAREHOUSE} + + {data.statDate ? ( + + 统计日 {data.statDate} + {data.stockSource === 'realtime' ? ' · 实际库存为实时' : ''} + + ) : null} + + )} + extra={( + handleExport(key), + }}> + + + )} + > + + { + 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: '该仓库下暂无库存数据' }} + /> + + + + ); +}; + +const ModularStockMonitoring = () => ( + +
+
+ 模块化库存监控 + + 按仓库查看 SKU 库存与销量的模块化视图,默认只取「{DEFAULT_WAREHOUSE}」仓库下的商品编码 + +
+
+ +
+); + +export default ModularStockMonitoring; diff --git a/frontend/src/pages/ModularStockMonitoring/style.css b/frontend/src/pages/ModularStockMonitoring/style.css new file mode 100644 index 0000000..25f477b --- /dev/null +++ b/frontend/src/pages/ModularStockMonitoring/style.css @@ -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; } +} diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 9e4352c..2761c18 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -17,6 +17,7 @@ const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePrev const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); +const ModularStockMonitoring = lazy(() => import('../pages/ModularStockMonitoring')); const lazyLoadingElement = ; @@ -101,6 +102,10 @@ const router = createBrowserRouter([ path: 'planning/operation/product-monitoring', element: renderLazyComponent(ProductMonitoring), }, + { + path: 'planning/operation/modular-stock-monitoring', + element: renderLazyComponent(ModularStockMonitoring), + }, { path: 'planning/operation/store-checkup', element: }, { path: 'planning/operation/review-analysis', element: }, { path: 'planning/operation/customer-service-checkup', element: },