feat: 客退数据监控五仓看板与本地运行稳定性加固
功能: - 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板, 按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2) - 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商 (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入) - 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单 - 服务端导出(xlsx/csv),不受列表分页与展示上限约束 稳定性: - 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500 - 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML - 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗 - 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时 合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现, 本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
This commit is contained in:
parent
6b0bf59e74
commit
b3f4d1af64
5
.gitignore
vendored
5
.gitignore
vendored
@ -62,3 +62,8 @@ tmall-board-speed-check.json
|
||||
# Local verification artifacts
|
||||
/outputs/
|
||||
.git-status.txt
|
||||
|
||||
# 本地一次性探查/调试产物(不进仓库)
|
||||
_probe_*.json
|
||||
_probe_*.js
|
||||
_work-scratch-*/
|
||||
|
||||
58
backend/src/api/returnInventoryWarning.js
Normal file
58
backend/src/api/returnInventoryWarning.js
Normal file
@ -0,0 +1,58 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getReturnInventoryWarningList,
|
||||
buildReturnInventoryWarningExport,
|
||||
} = require('../services/returnInventoryWarningService');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '退货库存预警请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ReturnInventoryWarning]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 退货库存预警明细列表(固定过滤仓库:销售退仓 横林二仓)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getReturnInventoryWarningList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载退货库存预警列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出退货入库明细(固定过滤仓库:销售退仓 横林二仓)
|
||||
* query: warehouse, date, keyword, format=xlsx|csv
|
||||
* 注意:导出走服务端全量拉取,不受列表分页限制。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
const format = String(query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const result = await buildReturnInventoryWarningExport(query);
|
||||
|
||||
console.log('[ReturnInventoryWarning][export]', {
|
||||
format,
|
||||
warehouse: result.warehouse,
|
||||
date: result.date,
|
||||
rows: result.totalRows,
|
||||
matchedTotal: result.matchedTotal,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
|
||||
sendWorkbookDownload(res, { fileName: result.fileName, format, sheets: result.sheets });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出退货入库明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
161
backend/src/api/returnMonitor.js
Normal file
161
backend/src/api/returnMonitor.js
Normal file
@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getDashboard,
|
||||
runDailyCompare,
|
||||
backfillHistory,
|
||||
getInStockLinesForCode,
|
||||
resolveDiscrepancy,
|
||||
updateInboundStatus,
|
||||
hasComparedToday,
|
||||
todayInShanghai,
|
||||
buildReturnMonitorExport,
|
||||
} = require('../services/returnMonitorService');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '退货入库核销请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ReturnMonitor]', {
|
||||
message: error?.message || fallback,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error?.message || fallback });
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板数据(KPI / 库龄分布 / 超期清单 / 按编码汇总 / 差异清单 / 入库明细)
|
||||
* query: date, overdueDays, status, keyword, page, pageSize, refresh=1
|
||||
*/
|
||||
router.get('/dashboard', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
const refresh = String(query.refresh || '') === '1';
|
||||
const bizDate = String(query.date || '').trim() || todayInShanghai();
|
||||
let compare = null;
|
||||
|
||||
// 首次打开或显式刷新时,先跑一轮「同步 + 比对」
|
||||
if (refresh || !(await hasComparedToday(bizDate))) {
|
||||
compare = await runDailyCompare({ date: bizDate, trigger: refresh ? 'manual' : 'api' });
|
||||
}
|
||||
|
||||
const data = await getDashboard(query);
|
||||
res.json({ success: true, data: { ...data, compare } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载退货入库核销看板失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 手动触发一轮「同步 + 每日比对」;backfillDays 用于补历史(库龄分布/超期清单) */
|
||||
router.post('/sync', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const date = String(body.date || '').trim() || todayInShanghai();
|
||||
const lookbackDays = body.lookbackDays === undefined ? 3 : Number(body.lookbackDays) || 0;
|
||||
|
||||
let backfill = null;
|
||||
if (body.backfillDays) {
|
||||
backfill = await backfillHistory({
|
||||
days: Number(body.backfillDays) || 180,
|
||||
toDate: date,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runDailyCompare({ date, trigger: 'manual', lookbackDays, bootstrapDays: 0 });
|
||||
res.json({ success: true, data: { ...result, backfill } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '同步退货入库数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出看板数据
|
||||
* query: scope=all|detail|overdue|code|discrepancy|age, format=xlsx|csv,
|
||||
* date, overdueDays, keyword, status
|
||||
* xlsx 支持多工作表;csv 只能承载一张表(scope=all 时自动降级为 detail)。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
const format = String(query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const requestedScope = String(query.scope || 'all').trim() || 'all';
|
||||
const scope = format === 'csv' && requestedScope === 'all' ? 'detail' : requestedScope;
|
||||
|
||||
const result = await buildReturnMonitorExport({
|
||||
date: query.date,
|
||||
overdueDays: query.overdueDays,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
scope,
|
||||
});
|
||||
|
||||
console.log('[ReturnMonitor][export]', {
|
||||
format,
|
||||
scope: result.scope,
|
||||
date: result.date,
|
||||
counts: result.counts,
|
||||
});
|
||||
|
||||
sendWorkbookDownload(res, { fileName: result.fileName, format, sheets: result.sheets });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出退货入库核销数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 某商品编码当前的全部在库入库行(差异清单「手动指定」勾选用) */
|
||||
router.get('/code-lines', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
res.json({
|
||||
success: true,
|
||||
data: await getInStockLinesForCode({
|
||||
productCode: query.productCode,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载该商品编码的在库入库行失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 处理差异清单:mode=fifo 采纳 FIFO 建议;mode=manual 手动指定行 */
|
||||
router.post('/discrepancy/resolve', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await resolveDiscrepancy({
|
||||
productCode: body.productCode,
|
||||
mode: body.mode === 'manual' ? 'manual' : 'fifo',
|
||||
ids: body.ids,
|
||||
outboundDate: body.outboundDate,
|
||||
operator: req.user?.username || req.user?.name || body.operator,
|
||||
remark: body.remark,
|
||||
});
|
||||
const data = await getDashboard({});
|
||||
res.json({ success: true, data: { result, dashboard: data } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '差异核销失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 人工标记单行状态(在库 / 已出库 / 已报废) */
|
||||
router.post('/inbound/mark', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await updateInboundStatus({
|
||||
id: body.id,
|
||||
status: body.status,
|
||||
outboundDate: body.outboundDate,
|
||||
operator: req.user?.username || req.user?.name || body.operator,
|
||||
remark: body.remark,
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '更新退货入库状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
132
backend/src/api/returnStockWarning.js
Normal file
132
backend/src/api/returnStockWarning.js
Normal file
@ -0,0 +1,132 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const {
|
||||
buildExportColumns,
|
||||
getReturnStockWarningExportRows,
|
||||
getReturnStockWarningList,
|
||||
resolveWarehouse,
|
||||
resolveWarehouseKey,
|
||||
} = require('../services/returnStockWarningService');
|
||||
const {
|
||||
clearStockWarningImport,
|
||||
importStockWarning,
|
||||
} = require('../services/returnStockWarningImport');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const resolveScope = (value) => (['imported', 'replica'].includes(String(value)) ? String(value) : 'all');
|
||||
|
||||
/**
|
||||
* GET /api/return-stock-warning/list —— 退货库存预警列表(客退数据监控,按仓库出数)
|
||||
* warehouse: 仓库标识 henlin2 / subei / suining / shandong / henan(缺省 = 横林二仓)
|
||||
*/
|
||||
router.get('/list', async (req, res, next) => {
|
||||
try {
|
||||
const payload = await getReturnStockWarningList({
|
||||
warehouse: req.query.warehouse,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
supplier: req.query.supplier,
|
||||
sortBy: req.query.sortBy,
|
||||
sortOrder: req.query.sortOrder,
|
||||
qtyBasis: req.query.qtyBasis,
|
||||
createdAtBasis: req.query.createdAtBasis,
|
||||
scope: resolveScope(req.query.scope),
|
||||
focusDays: req.query.focusDays,
|
||||
alertDays: req.query.alertDays,
|
||||
warningLevel: req.query.warningLevel,
|
||||
priceFocusMin: req.query.priceFocusMin,
|
||||
priceHighMin: req.query.priceHighMin,
|
||||
priceLevel: req.query.priceLevel,
|
||||
returnLimitDays: req.query.returnLimitDays,
|
||||
refresh: req.query.refresh,
|
||||
});
|
||||
res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/return-stock-warning/export —— 服务端全量导出(xlsx / csv) */
|
||||
router.get('/export', async (req, res, next) => {
|
||||
try {
|
||||
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const keyword = String(req.query.keyword || '').trim();
|
||||
const warehouse = resolveWarehouse(req.query.warehouse);
|
||||
const rows = await getReturnStockWarningExportRows({
|
||||
warehouse: req.query.warehouse,
|
||||
keyword,
|
||||
supplier: req.query.supplier,
|
||||
qtyBasis: req.query.qtyBasis,
|
||||
createdAtBasis: req.query.createdAtBasis,
|
||||
scope: resolveScope(req.query.scope),
|
||||
focusDays: req.query.focusDays,
|
||||
alertDays: req.query.alertDays,
|
||||
warningLevel: req.query.warningLevel,
|
||||
priceFocusMin: req.query.priceFocusMin,
|
||||
priceHighMin: req.query.priceHighMin,
|
||||
priceLevel: req.query.priceLevel,
|
||||
returnLimitDays: req.query.returnLimitDays,
|
||||
refresh: req.query.refresh,
|
||||
});
|
||||
|
||||
const exportColumns = buildExportColumns(warehouse);
|
||||
sendWorkbookDownload(res, {
|
||||
fileName: `${warehouse.shortName}客退数据监控_${keyword || '全部'}`,
|
||||
format,
|
||||
sheets: [{
|
||||
name: `${warehouse.shortName}客退数据监控`,
|
||||
header: exportColumns.map((column) => column.title),
|
||||
rows: rows.map((row) => exportColumns.map((column) => row[column.key])),
|
||||
}],
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/return-stock-warning/import —— 导入聚水潭「箱及仓位库存」导出文件 */
|
||||
router.post('/import', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file || !req.file.buffer) {
|
||||
res.status(400).json({ success: false, message: '请选择要导入的文件(xlsx / xls / csv)' });
|
||||
return;
|
||||
}
|
||||
// multer 按 latin1 解析 multipart 文件名,中文会变成乱码,这里转回 UTF-8
|
||||
const rawName = req.file.originalname || '';
|
||||
const fileName = /[\u00c0-\u00ff]/.test(rawName)
|
||||
? Buffer.from(rawName, 'latin1').toString('utf8')
|
||||
: rawName;
|
||||
|
||||
const result = await importStockWarning({
|
||||
buffer: req.file.buffer,
|
||||
fileName,
|
||||
importedBy: req.user?.username || req.user?.name || null,
|
||||
// 导入落在哪个仓库下:优先取表单字段,其次 query,最后回落默认仓
|
||||
warehouseKey: resolveWarehouseKey(req.body?.warehouse || req.query.warehouse),
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/return-stock-warning/import —— 清除当前仓库的导入,回到系统推算口径 */
|
||||
router.delete('/import', async (req, res, next) => {
|
||||
try {
|
||||
const result = await clearStockWarningImport(resolveWarehouseKey(req.query.warehouse));
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -542,6 +542,10 @@ function normalizeMssqlConfig(rawConfig, meta = {}) {
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
// 业务库的 DATETIME 存的是北京时间(无时区标记)。mssql 驱动默认 useUTC=true 会把它
|
||||
// 当成 UTC 解析,而本项目所有时间格式化都是用本地 getter 还原墙上时间,两者叠加会让
|
||||
// 显示值整体 +8 小时(例如库里 16:56:07 被显示成次日 00:56:07)。按本地时间解析才对得上。
|
||||
useUTC: false,
|
||||
},
|
||||
pool: {
|
||||
max: CLOUD_DB_POOL_MAX,
|
||||
|
||||
@ -35,6 +35,9 @@ const promotionModuleRoutes = require('./api/promotionModule');
|
||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
||||
const mkhBomRoutes = require('./api/mkhBom');
|
||||
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
||||
const returnMonitorRoutes = require('./api/returnMonitor');
|
||||
const returnStockWarningRoutes = require('./api/returnStockWarning');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
@ -659,6 +662,11 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
|
||||
// 横林二仓客退:退货入库核销 + 每日库存监控
|
||||
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
|
||||
// 横林二仓客退:退货库存预警(箱及仓位库存 · 客退临时上架仓)
|
||||
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
|
||||
|
||||
// =====================
|
||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||
@ -694,6 +702,36 @@ async function startServer(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// 统一 API 兜底(必须放在所有路由之后)
|
||||
// =====================
|
||||
// 背景:此前没有任何 4 参错误处理中间件,路由里抛出的异常会落到 Express
|
||||
// 默认处理器,返回一张 HTML 的 "Internal Server Error" 页面;前端 axios
|
||||
// 读不到 error / message 字段,只能显示 "Request failed with status code 500",
|
||||
// 出问题时完全看不到真实原因。这里统一返回 JSON 并打印堆栈。
|
||||
cubejsApp.use('/api', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: `接口不存在: ${req.method} ${req.originalUrl}`,
|
||||
});
|
||||
});
|
||||
|
||||
cubejsApp.use((err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
const rawStatus = Number(err?.status || err?.statusCode);
|
||||
const status = Number.isFinite(rawStatus) && rawStatus >= 400 && rawStatus <= 599
|
||||
? rawStatus
|
||||
: 500;
|
||||
const messageText = err?.message || '服务器内部错误';
|
||||
console.error(`❌ [API ${status}] ${req.method} ${req.originalUrl} -> ${messageText}`);
|
||||
if (err?.stack) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
return res.status(status).json({ success: false, error: messageText });
|
||||
});
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('🚀 后端服务已启动');
|
||||
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
|
||||
|
||||
34
backend/src/jobs/returnMonitorJob.js
Normal file
34
backend/src/jobs/returnMonitorJob.js
Normal file
@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const { runDailyCompare, todayInShanghai } = require('../services/returnMonitorService');
|
||||
|
||||
// 每天 07:00(东八区)跑一轮:同步当日退货入库 + 现有库存 + 每日比对核销
|
||||
const JOB_CRON = String(process.env.RETURN_MONITOR_CRON || '0 7 * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const LOOKBACK_DAYS = Number(process.env.RETURN_MONITOR_LOOKBACK_DAYS || 3);
|
||||
|
||||
async function runReturnMonitorCompare(trigger = 'cron') {
|
||||
const bizDate = todayInShanghai();
|
||||
try {
|
||||
const result = await runDailyCompare({ date: bizDate, trigger, lookbackDays: LOOKBACK_DAYS });
|
||||
console.log(`[ReturnMonitor] 每日比对完成: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[ReturnMonitor] 每日比对失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startReturnMonitorScheduler() {
|
||||
if (String(process.env.RETURN_MONITOR_JOB_DISABLED || '').trim().toLowerCase() === 'true') {
|
||||
console.log('[ReturnMonitor] 退货入库核销定时任务已禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runReturnMonitorCompare('cron').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 横林二仓客退库龄核销任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { startReturnMonitorScheduler, runReturnMonitorCompare };
|
||||
100
backend/src/services/exportWorkbook.js
Normal file
100
backend/src/services/exportWorkbook.js
Normal file
@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 通用表格导出
|
||||
* --------------------------------------------------------------------------
|
||||
* 把 [{ name, header: string[], rows: any[][] }] 渲染成:
|
||||
* - xlsx:多工作表工作簿(基于 backend 已依赖的 xlsx 包)
|
||||
* - csv :单工作表、带 UTF-8 BOM(Excel 直接打开中文不乱码)
|
||||
* 语义与 frontend/src/utils/tableExport.js 的「表头 + 行」保持一致。
|
||||
*/
|
||||
|
||||
const xlsx = require('xlsx');
|
||||
|
||||
const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
|
||||
|
||||
// Excel 工作表名限制:<=31 字符,且不能包含 : \ / ? * [ ]
|
||||
const sanitizeSheetName = (name, fallback) => {
|
||||
const cleaned = String(name || '').replace(/[:\\/?*[\]]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return (cleaned || fallback).slice(0, 31);
|
||||
};
|
||||
|
||||
const toCellValue = (value) => (
|
||||
value === null || value === undefined || value === '' ? '' : value
|
||||
);
|
||||
|
||||
const sheetToAoa = (sheet) => ([
|
||||
(sheet?.header || []).map(toCellValue),
|
||||
...((sheet?.rows || []).map((row) => (row || []).map(toCellValue))),
|
||||
]);
|
||||
|
||||
/** 多工作表 xlsx buffer;工作表名重复/非法时自动去重与截断 */
|
||||
const buildXlsxBuffer = (sheets = []) => {
|
||||
const workbook = xlsx.utils.book_new();
|
||||
const usedNames = new Set();
|
||||
|
||||
sheets.forEach((sheet, index) => {
|
||||
const base = sanitizeSheetName(sheet?.name, `工作表${index + 1}`);
|
||||
let name = base;
|
||||
let suffix = 2;
|
||||
while (usedNames.has(name)) {
|
||||
name = `${base.slice(0, 27)}(${suffix})`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedNames.add(name);
|
||||
|
||||
const worksheet = xlsx.utils.aoa_to_sheet(sheetToAoa(sheet));
|
||||
worksheet['!cols'] = (sheet?.header || []).map((title) => ({
|
||||
wch: Math.max(10, Math.min(32, String(title || '').length * 2 + 6)),
|
||||
}));
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, name);
|
||||
});
|
||||
|
||||
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
};
|
||||
|
||||
const quoteCsvCell = (value) => {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
};
|
||||
|
||||
/** 单工作表 csv buffer(只取第一个工作表),前置 BOM */
|
||||
const buildCsvBuffer = (sheets = []) => {
|
||||
const sheet = sheets[0] || { header: [], rows: [] };
|
||||
const lines = [(sheet.header || []).map(quoteCsvCell).join(',')];
|
||||
(sheet.rows || []).forEach((row) => {
|
||||
lines.push((row || []).map(quoteCsvCell).join(','));
|
||||
});
|
||||
return Buffer.from(`\ufeff${lines.join('\r\n')}`, 'utf8');
|
||||
};
|
||||
|
||||
const sanitizeFileName = (fileName) => String(fileName || '导出数据').replace(/[\\/:*?"<>|]/g, '_').trim() || '导出数据';
|
||||
|
||||
/**
|
||||
* 直接把导出结果写回响应。
|
||||
* @param {import('express').Response} res
|
||||
* @param {{ fileName?: string, format?: 'xlsx'|'csv', sheets?: Array }} payload
|
||||
*/
|
||||
const sendWorkbookDownload = (res, { fileName, format = 'xlsx', sheets = [] } = {}) => {
|
||||
const isCsv = String(format).toLowerCase() === 'csv';
|
||||
const exportSheets = isCsv ? sheets.slice(0, 1) : sheets;
|
||||
const buffer = isCsv ? buildCsvBuffer(exportSheets) : buildXlsxBuffer(exportSheets);
|
||||
const totalRows = exportSheets.reduce((sum, sheet) => sum + ((sheet?.rows || []).length), 0);
|
||||
const fullName = `${sanitizeFileName(fileName)}${isCsv ? '.csv' : '.xlsx'}`;
|
||||
|
||||
res.setHeader('Content-Type', isCsv ? CSV_CONTENT_TYPE : XLSX_CONTENT_TYPE);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fullName)}`);
|
||||
res.setHeader('X-Export-Count', String(totalRows));
|
||||
res.send(buffer);
|
||||
return { totalRows, fullName };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
XLSX_CONTENT_TYPE,
|
||||
CSV_CONTENT_TYPE,
|
||||
sanitizeSheetName,
|
||||
buildXlsxBuffer,
|
||||
buildCsvBuffer,
|
||||
sendWorkbookDownload,
|
||||
};
|
||||
@ -12,6 +12,11 @@ const {
|
||||
ensureProductMonitoringSchema,
|
||||
warmProductMonitoringDataset,
|
||||
} = require('./productMonitoringService');
|
||||
const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
|
||||
const {
|
||||
ensureReturnStockWarningSchema,
|
||||
} = require('./returnStockWarningSchema');
|
||||
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
|
||||
|
||||
const USER_COLUMNS = [
|
||||
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
|
||||
@ -952,6 +957,10 @@ async function ensureLocalSchema() {
|
||||
);
|
||||
|
||||
await ensureProductMonitoringSchema();
|
||||
await ensureReturnMonitorSchema();
|
||||
await ensureReturnStockWarningSchema();
|
||||
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
|
||||
void warmReturnStockWarningCache();
|
||||
|
||||
await ensureSidebarRolePermissions();
|
||||
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||
|
||||
412
backend/src/services/returnInventoryWarningService.js
Normal file
412
backend/src/services/returnInventoryWarningService.js
Normal file
@ -0,0 +1,412 @@
|
||||
'use strict';
|
||||
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
|
||||
/**
|
||||
* 退货库存预警服务
|
||||
* --------------------------------------------------------------------------
|
||||
* 数据来源:业务数据库(MSSQL 报表副本 / JSReportReplica, schema rpt)。
|
||||
* 固定过滤仓库:「销售退仓 横林二仓」对应业务库仓库名「简尚-横林二仓销退仓」。
|
||||
*
|
||||
* 日期口径(重要):
|
||||
* 列表按「退货入库日期」筛选,取 jst_returnAndRefundItem.receive_date(明细级收货日期),
|
||||
* 而不是 jst_returnAndRefundInfo.as_date(售后申请日期)。原因:
|
||||
* - as_date 是售后单申请日,很多退货是几天后才实际收货入库的;
|
||||
* - 只按 as_date 过滤,会漏掉"前几天申请、今天才收货入库"的明细。
|
||||
* 实测(2026-09-12):按 as_date 过滤只有 366 条明细;按 receive_date 过滤有 745 条明细,
|
||||
* 更接近聚水潭「销售退仓」当天口径。
|
||||
*
|
||||
* 数据来源口径(重要,2026-09-15 修正):
|
||||
* 副本里有两条互不重叠的退货入库同步管线,必须取并集才对得上聚水潭「销售退仓」:
|
||||
* ① ERP 售后单 jst_returnAndRefundInfo × jst_returnAndRefundItem
|
||||
* ② 奇门退款单 jst_qmcust_refundlist × jst_qmcust_refundlist_item
|
||||
* 去重键 = (退货单号 as_id, 商品编码 sku_id),同键优先取 ERP 行。
|
||||
*
|
||||
* 字段映射(业务库 -> 逻辑字段,与前端页面表头一一对应):
|
||||
* 退货入库时间 returnInboundTime <- jst_returnAndRefundItem.receive_date(回退 info.as_date)
|
||||
* 快递单号 expressNo <- jst_returnAndRefundInfo.l_id
|
||||
* 供应商 supplier <- jst_sku.supplier_name
|
||||
* 预警 warning <- ope_clear_task.alert(按 sku 取最新月)
|
||||
* 商品编码 productCode <- jst_returnAndRefundItem.sku_id
|
||||
* 入库件数 inboundQty <- jst_returnAndRefundItem.r_qty(实收件数;
|
||||
* 当 r_qty 为 0 时回退到 qty)
|
||||
* 在库天数 stockDays <- DATEDIFF(DAY, 入库日期, 出库日期);出库未发生 → null
|
||||
* 退货总库存 returnTotalStock <- jst_inventoryInfo.return_qty(横林二仓 wms_co_id=15033302)
|
||||
* 出库日期 outboundDate <- 业务库暂无 采购退货 表 → null
|
||||
* 出库件数 outboundQty <- 业务库暂无对应来源 → null
|
||||
*
|
||||
* 说明:dbo.* 引用会被 cloudDatabase.rewriteReportReplicaSql 改写为 rpt.*。
|
||||
* 仓库过滤条件以字面量 N'...' 写入 SQL,规避中文参数化时的 Unicode 问题。
|
||||
* 若业务库不可用或查询失败,接口兜底返回演示数据并附带 notice。
|
||||
*/
|
||||
|
||||
// 前端展示名(入参)与业务库真实仓库名映射
|
||||
const WAREHOUSE_DISPLAY = '销售退仓 横林二仓';
|
||||
const WAREHOUSE_DB = '简尚-横林二仓销退仓';
|
||||
// 横林二仓在 jst_inventoryInfo 中的仓编码(wh_id=2)
|
||||
const HENLIN2_WMS_CO_ID = 15033302;
|
||||
// 单页最大条数(一天 1000+ 条,留足余量)
|
||||
const MAX_PAGE_SIZE = 3000;
|
||||
|
||||
// 已知的副本缺口(前端 / 业务侧可见)
|
||||
const KNOWN_GAPS = [
|
||||
'退货总库存:期望"箱及仓位库存-横林二仓-客退临时上架仓"(仓位级),但 rpt 副本仅有 jst_inventoryInfo(仓+SKU 级),无仓位维度。当前按 wms_co_id=15033302 取 return_qty 作为仓级近似值。',
|
||||
'出库日期/出库件数:期望"聚水潭采购退货-横林二仓",但 rpt 副本中没有采购退货表(jst_purchase/jst_purchaseItem 是采购入库单据)。当前为 null。',
|
||||
'条数口径(2026-09-15 已修正):本表取「ERP 售后单 + 奇门退款单」两条同步管线的并集,去重键为(退货单号 + 商品编码)。实测 09-14 并集 1179 条 vs 聚水潭「销售退仓」1194 条(吻合度 98.7%),较旧的单表口径(797 条 / 66.8%)大幅修正。剩余 1~2% 差额来自副本未覆盖的 WMS 退仓单据(退仓单号 / 上架状态 / 箱号维度在 rpt 副本中不存在)。',
|
||||
];
|
||||
|
||||
function resolveWarehouseDbName(incoming) {
|
||||
const v = String(incoming || '').trim();
|
||||
if (!v || v === WAREHOUSE_DISPLAY) return WAREHOUSE_DB;
|
||||
return v; // 允许直接传入真实仓库名
|
||||
}
|
||||
|
||||
// 取"今天"(东八区)的 YYYY-MM-DD
|
||||
function todayInShanghai() {
|
||||
const now = new Date();
|
||||
const utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
|
||||
const shMs = utcMs + 8 * 3600000;
|
||||
return new Date(shMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
// 接受 YYYY-MM-DD 或 YYYY/MM/DD
|
||||
const m = v.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
|
||||
if (!m) return '';
|
||||
const mm = String(m[2]).padStart(2, '0');
|
||||
const dd = String(m[3]).padStart(2, '0');
|
||||
return `${m[1]}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
// 演示数据(仅当业务库查询失败时兜底,便于核对页面布局与字段)
|
||||
const DEMO_ROWS = [
|
||||
{ returnInboundTime: '2026-09-12 15:38:03', expressNo: '76024510836500010001', supplier: '越子涵', warning: '正常', productCode: 'SZ001541N961V01', inboundQty: 1, stockDays: null, returnTotalStock: 0, outboundDate: null, outboundQty: null },
|
||||
{ returnInboundTime: '2026-09-12 15:37:49', expressNo: '730006113799', supplier: '艾米莉', warning: '正常', productCode: 'SJ004831N1172V01', inboundQty: 1, stockDays: null, returnTotalStock: 0, outboundDate: null, outboundQty: null },
|
||||
{ returnInboundTime: '2026-09-11 10:20:00', expressNo: 'SF2025514008294', supplier: '常州苗苗', warning: '黄色预警', productCode: 'SJZ000316N1253V01', inboundQty: 12, stockDays: null, returnTotalStock: 86, outboundDate: null, outboundQty: null },
|
||||
{ returnInboundTime: '2026-09-10 09:12:00', expressNo: 'SF1234567890123', supplier: '横林家居厂', warning: '红色预警', productCode: 'HL20240001', inboundQty: 120, stockDays: null, returnTotalStock: 320, outboundDate: null, outboundQty: null },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据来源(2026-09-15 修正):两条同步管线取并集
|
||||
// ---------------------------------------------------------------------------
|
||||
// 副本里「退货入库」其实有两条互相独立的同步管线,且数据几乎不重叠:
|
||||
// ① ERP 售后单:dbo.jst_returnAndRefundInfo × dbo.jst_returnAndRefundItem(状态以 WaitConfirm 为主)
|
||||
// ② 奇门退款单:dbo.jst_qmcust_refundlist × dbo.jst_qmcust_refundlist_item(状态以 Confirmed 为主)
|
||||
// 实测 2026-09-14:① 797 条、② 497 条,仅 115 条重叠(as_id + SKU 完全一致),并集 1179 条;
|
||||
// 聚水潭「销售退仓」界面当天 1194 条 → 吻合度 98.7%(只用 ① 仅 66.8%)。
|
||||
// 因此统一取并集,去重键 = (退货单号, 商品编码),同键优先取 ERP 售后单(字段更全)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSourceCte(date) {
|
||||
const mainDateClause = date ? `AND CAST(it.receive_date AS DATE) = @date` : '';
|
||||
const qmDateClause = date ? `AND CAST(qit.receive_date AS DATE) = @date` : '';
|
||||
return `
|
||||
WITH src AS (
|
||||
SELECT
|
||||
CONVERT(NVARCHAR(50), i.as_id) AS as_id,
|
||||
LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) AS product_code,
|
||||
ISNULL(NULLIF(it.r_qty, 0), it.qty) AS inbound_qty,
|
||||
COALESCE(it.receive_date, i.as_date) AS inbound_time,
|
||||
i.l_id AS express_no,
|
||||
1 AS src_rank,
|
||||
N'ERP售后单' AS src_label
|
||||
FROM dbo.jst_returnAndRefundInfo i
|
||||
JOIN dbo.jst_returnAndRefundItem it ON it.as_id = i.as_id
|
||||
WHERE i.warehouse = N'${WAREHOUSE_DB}'
|
||||
AND it.sku_id IS NOT NULL
|
||||
AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) <> N''
|
||||
${mainDateClause}
|
||||
UNION ALL
|
||||
SELECT
|
||||
CONVERT(NVARCHAR(50), q.as_id),
|
||||
LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))),
|
||||
ISNULL(NULLIF(qit.r_qty, 0), qit.qty),
|
||||
COALESCE(qit.receive_date, q.as_date),
|
||||
q.l_id,
|
||||
2,
|
||||
N'奇门退款单'
|
||||
FROM dbo.jst_qmcust_refundlist q
|
||||
JOIN dbo.jst_qmcust_refundlist_item qit
|
||||
ON CONVERT(NVARCHAR(50), q.as_id) = CONVERT(NVARCHAR(50), qit.as_id)
|
||||
WHERE q.warehouse = N'${WAREHOUSE_DB}'
|
||||
AND qit.sku_id IS NOT NULL
|
||||
AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))) <> N''
|
||||
${qmDateClause}
|
||||
),
|
||||
dedup AS (
|
||||
SELECT as_id, product_code, inbound_qty, inbound_time, express_no, src_label
|
||||
FROM (
|
||||
SELECT src.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY src.as_id, UPPER(src.product_code)
|
||||
ORDER BY src.src_rank
|
||||
) AS rn
|
||||
FROM src
|
||||
) ranked
|
||||
WHERE ranked.rn = 1
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const FROM_JOINS = `
|
||||
FROM dedup u
|
||||
LEFT JOIN dbo.jst_sku sk ON sk.sku_id = u.product_code
|
||||
`;
|
||||
|
||||
const SELECT_COLUMNS = `
|
||||
CONVERT(NVARCHAR(19), u.inbound_time, 120) AS [returnInboundTime],
|
||||
u.express_no AS [expressNo],
|
||||
sk.supplier_name AS [supplier],
|
||||
NULLIF(LTRIM(RTRIM(COALESCE(ct.alert, N''))), N'') AS [warning],
|
||||
u.product_code AS [productCode],
|
||||
u.inbound_qty AS [inboundQty],
|
||||
ISNULL(inv.return_qty, 0) AS [returnTotalStock],
|
||||
NULL AS [outboundDate],
|
||||
NULL AS [outboundQty],
|
||||
u.src_label AS [dataSource]
|
||||
`;
|
||||
|
||||
const OUTER_APPLIES = `
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 alert
|
||||
FROM dbo.ope_clear_task ct
|
||||
WHERE ct.clear_skuoid = u.product_code
|
||||
ORDER BY ct.stat_month DESC
|
||||
) ct
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 return_qty
|
||||
FROM dbo.jst_inventoryInfo inv
|
||||
WHERE inv.sku_id = u.product_code AND inv.wms_co_id = ${HENLIN2_WMS_CO_ID}
|
||||
) inv
|
||||
`;
|
||||
|
||||
// 关键字过滤:与页面上的「商品编码 / 快递单号」搜索保持一致
|
||||
const buildKeywordClause = (keyword) => (
|
||||
keyword ? `AND (u.product_code LIKE @keyword OR u.express_no LIKE @keyword)` : ''
|
||||
);
|
||||
|
||||
function buildListSql({ offset, limit, date, keyword }) {
|
||||
return `
|
||||
${buildSourceCte(date)}
|
||||
SELECT ${SELECT_COLUMNS}
|
||||
${FROM_JOINS}
|
||||
${OUTER_APPLIES}
|
||||
WHERE 1 = 1
|
||||
${buildKeywordClause(keyword)}
|
||||
ORDER BY u.inbound_time DESC, u.as_id DESC
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCountSql({ date, keyword }) {
|
||||
return `
|
||||
${buildSourceCte(date)}
|
||||
SELECT COUNT(*) AS [total]
|
||||
FROM dedup u
|
||||
WHERE 1 = 1
|
||||
${buildKeywordClause(keyword)}
|
||||
`;
|
||||
}
|
||||
|
||||
// 该仓「最新一条明细收货日」——用于前端在"今天还没同步到"时回退到最新有数据的日期
|
||||
function buildLatestDateSql() {
|
||||
return `
|
||||
${buildSourceCte('')}
|
||||
SELECT CONVERT(varchar(10), MAX(u.inbound_time), 120) AS [latestDate]
|
||||
FROM dedup u
|
||||
`;
|
||||
}
|
||||
|
||||
async function fetchLatestDate() {
|
||||
try {
|
||||
const rows = await cloudDb.query(buildLatestDateSql(), {}, { requestTimeout: 180000 });
|
||||
return rows?.[0]?.latestDate || null;
|
||||
} catch (error) {
|
||||
return null; // 单独失败不影响主列表
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRow(row = {}) {
|
||||
return {
|
||||
returnInboundTime: row.returnInboundTime ?? null,
|
||||
expressNo: row.expressNo ?? null,
|
||||
supplier: row.supplier ?? null,
|
||||
warning: row.warning ?? null,
|
||||
productCode: row.productCode ?? null,
|
||||
inboundQty: row.inboundQty === null || row.inboundQty === undefined ? 0 : Number(row.inboundQty),
|
||||
// 在库天数:当前副本无 采购退货 来源,固定为 null(有出库日期后再启用 DATEDIFF)
|
||||
stockDays: null,
|
||||
returnTotalStock: (row.returnTotalStock === null || row.returnTotalStock === undefined) ? null : Number(row.returnTotalStock),
|
||||
outboundDate: null,
|
||||
outboundQty: null,
|
||||
// 并集口径下的数据来源:ERP售后单 / 奇门退款单(便于与聚水潭逐条核对)
|
||||
dataSource: row.dataSource || null,
|
||||
};
|
||||
}
|
||||
|
||||
// 统一的查询口径解析:仓库展示名、业务库仓库名、日期(默认今天,'all'/'' = 不限)、关键字
|
||||
function resolveListQuery(params = {}) {
|
||||
const displayWarehouse = params.warehouse || WAREHOUSE_DISPLAY;
|
||||
const warehouse = resolveWarehouseDbName(displayWarehouse);
|
||||
|
||||
// 日期口径:默认今天(东八区);传 date='' 或 'all' 表示不限日期
|
||||
const rawDate = params.date === undefined ? todayInShanghai() : String(params.date || '').trim();
|
||||
const date = (rawDate === '' || rawDate.toLowerCase() === 'all') ? '' : normalizeDateInput(rawDate);
|
||||
|
||||
const keyword = String(params.keyword || '').trim();
|
||||
return { displayWarehouse, warehouse, date, keyword };
|
||||
}
|
||||
|
||||
function buildSqlParams({ date, keyword }, extra = {}) {
|
||||
const sqlParams = { ...extra };
|
||||
if (date) sqlParams.date = date;
|
||||
if (keyword) sqlParams.keyword = `%${keyword}%`;
|
||||
return sqlParams;
|
||||
}
|
||||
|
||||
async function getReturnInventoryWarningList(params = {}) {
|
||||
const { displayWarehouse, date, keyword } = resolveListQuery(params);
|
||||
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.pageSize, 10) || 500));
|
||||
const offset = (page - 1) * pageSize;
|
||||
const dateBasis = 'receive_date';
|
||||
|
||||
try {
|
||||
const [rows, countRows, latestDate] = await Promise.all([
|
||||
cloudDb.query(
|
||||
buildListSql({ offset, limit: pageSize, date, keyword }),
|
||||
buildSqlParams({ date, keyword }, { offset, limit: pageSize }),
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
cloudDb.query(buildCountSql({ date, keyword }), buildSqlParams({ date, keyword }), { requestTimeout: 180000 }),
|
||||
fetchLatestDate(),
|
||||
]);
|
||||
|
||||
const normalized = (rows || []).map(normalizeRow).filter((row) => row.productCode);
|
||||
const total = Number(countRows?.[0]?.total ?? normalized.length);
|
||||
|
||||
return {
|
||||
rows: normalized,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
warehouse: displayWarehouse,
|
||||
dateBasis,
|
||||
date: date || null,
|
||||
latestDate,
|
||||
dateIsEmpty: total === 0,
|
||||
isDemo: false,
|
||||
gaps: KNOWN_GAPS,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error?.message || '未知错误';
|
||||
return {
|
||||
rows: DEMO_ROWS.map((row) => ({ ...row })),
|
||||
total: DEMO_ROWS.length,
|
||||
page,
|
||||
pageSize,
|
||||
warehouse: displayWarehouse,
|
||||
dateBasis,
|
||||
date: date || null,
|
||||
latestDate: null,
|
||||
dateIsEmpty: true,
|
||||
isDemo: true,
|
||||
notice: `业务库查询失败,当前展示演示数据。原始错误:${message}`,
|
||||
gaps: KNOWN_GAPS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 导出
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 导出时按批拉取,避免一次 OFFSET/FETCH 过大导致副本查询超时
|
||||
const EXPORT_PAGE_SIZE = 2000;
|
||||
const EXPORT_MAX_ROWS = 50000;
|
||||
|
||||
// 与页面表头一一对应
|
||||
const EXPORT_COLUMNS = [
|
||||
{ title: '退货入库时间', key: 'returnInboundTime' },
|
||||
{ title: '快递单号', key: 'expressNo' },
|
||||
{ title: '供应商', key: 'supplier' },
|
||||
{ title: '预警', key: 'warning' },
|
||||
{ title: '商品编码', key: 'productCode' },
|
||||
{ title: '入库件数', key: 'inboundQty' },
|
||||
{ title: '在库天数', key: 'stockDays' },
|
||||
{ title: '退货总库存', key: 'returnTotalStock' },
|
||||
{ title: '出库日期', key: 'outboundDate' },
|
||||
{ title: '出库件数', key: 'outboundQty' },
|
||||
{ title: '数据来源', key: 'dataSource' },
|
||||
];
|
||||
|
||||
/** 拉取当前筛选条件下的全部明细(分页拉取,最多 EXPORT_MAX_ROWS 行) */
|
||||
async function getReturnInventoryWarningExportRows(params = {}) {
|
||||
const { displayWarehouse, date, keyword } = resolveListQuery(params);
|
||||
const maxRows = Math.max(1, parseInt(params.limit, 10) || EXPORT_MAX_ROWS);
|
||||
|
||||
const countPromise = cloudDb
|
||||
.query(buildCountSql({ date, keyword }), buildSqlParams({ date, keyword }), { requestTimeout: 180000 })
|
||||
.catch(() => null);
|
||||
|
||||
const rows = [];
|
||||
let page = 1;
|
||||
|
||||
while (rows.length < maxRows) {
|
||||
const offset = (page - 1) * EXPORT_PAGE_SIZE;
|
||||
const limit = Math.min(EXPORT_PAGE_SIZE, maxRows - rows.length);
|
||||
|
||||
const batch = await cloudDb.query(
|
||||
buildListSql({ offset, limit, date, keyword }),
|
||||
buildSqlParams({ date, keyword }, { offset, limit }),
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const normalized = (batch || []).map(normalizeRow).filter((row) => row.productCode);
|
||||
rows.push(...normalized);
|
||||
|
||||
if (!batch || batch.length < limit) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
const countRows = await countPromise;
|
||||
const total = Number(countRows?.[0]?.total ?? rows.length);
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
truncated: total > rows.length,
|
||||
warehouse: displayWarehouse,
|
||||
date: date || null,
|
||||
keyword,
|
||||
};
|
||||
}
|
||||
|
||||
/** 组装导出数据集(供 api 层渲染成 xlsx / csv) */
|
||||
async function buildReturnInventoryWarningExport(params = {}) {
|
||||
const { rows, total, warehouse, date, keyword, truncated } = await getReturnInventoryWarningExportRows(params);
|
||||
|
||||
return {
|
||||
fileName: `横林二仓当日退货入库明细_${date || '全部日期'}${keyword ? `_${keyword}` : ''}`,
|
||||
sheets: [{
|
||||
name: '退货入库明细',
|
||||
header: EXPORT_COLUMNS.map((column) => column.title),
|
||||
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
|
||||
}],
|
||||
totalRows: rows.length,
|
||||
matchedTotal: total,
|
||||
truncated,
|
||||
warehouse,
|
||||
date,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getReturnInventoryWarningList,
|
||||
getReturnInventoryWarningExportRows,
|
||||
buildReturnInventoryWarningExport,
|
||||
DEFAULT_WAREHOUSE: WAREHOUSE_DISPLAY,
|
||||
todayInShanghai,
|
||||
};
|
||||
128
backend/src/services/returnMonitorSchema.js
Normal file
128
backend/src/services/returnMonitorSchema.js
Normal file
@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 横林二仓客退「退货入库核销 + 每日库存监控」本地表结构
|
||||
* --------------------------------------------------------------------------
|
||||
* 为什么需要本地表:
|
||||
* 业务库(JSReportReplica)只有退货单据的"事实",没有"核销状态"。
|
||||
* 而本需求的「状态(在库/已出库/已报废)」「出库日期」「差异人工确认结果」
|
||||
* 是每天比对后累积出来的状态,必须落库持久化,否则每天重算会丢历史。
|
||||
*
|
||||
* 存放位置:系统库(LOCAL_DB_*,默认 shujuku2_system)
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const SCHEMA_SQL = [
|
||||
// 1. 核心:退货入库主数据(一单一行 = 一个退货单的一个商品行)
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_inbound (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
return_no VARCHAR(120) NOT NULL COMMENT '退货单号(聚水潭售后单号 as_id)',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码(sku_id)',
|
||||
sku_name VARCHAR(255) NULL COMMENT '商品名称',
|
||||
supplier VARCHAR(255) NULL COMMENT '供应商',
|
||||
qty INT NOT NULL DEFAULT 0 COMMENT '数量(入库件数)',
|
||||
inbound_date DATE NOT NULL COMMENT '入库日期(取单据/收货日期)',
|
||||
express_no VARCHAR(120) NULL COMMENT '快递单号',
|
||||
order_no VARCHAR(120) NULL COMMENT '原订单号',
|
||||
defective_qty INT NULL COMMENT '来源单据的不良品数量(如有)',
|
||||
status VARCHAR(16) NOT NULL DEFAULT '在库' COMMENT '状态:在库/已出库/已报废',
|
||||
outbound_date DATE NULL COMMENT '出库日期',
|
||||
writeoff_mode VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT '核销方式:none/auto_clear/fifo/manual/mark',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次同步时间',
|
||||
last_synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近同步时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_rm_inbound_line (warehouse_key, return_no, product_code, inbound_date),
|
||||
KEY idx_rm_inbound_status (warehouse_key, status, inbound_date),
|
||||
KEY idx_rm_inbound_code (warehouse_key, product_code),
|
||||
KEY idx_rm_inbound_date (warehouse_key, inbound_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-退货入库主数据(核销状态)'`,
|
||||
|
||||
// 2. 现有库存每日快照(按商品编码)
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_stock (
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
stat_date DATE NOT NULL COMMENT '快照日期',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||||
stock_qty INT NOT NULL DEFAULT 0 COMMENT '现有库存件数',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
|
||||
PRIMARY KEY (warehouse_key, stat_date, product_code),
|
||||
KEY idx_rm_stock_code (warehouse_key, product_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-现有库存每日快照'`,
|
||||
|
||||
// 3. 差异清单(部分出库,需人工确认)
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_discrepancy (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||||
supplier VARCHAR(255) NULL COMMENT '供应商',
|
||||
inbound_total INT NOT NULL DEFAULT 0 COMMENT '该编码在库入库总件数',
|
||||
stock_qty INT NOT NULL DEFAULT 0 COMMENT '现有库存件数',
|
||||
diff_qty INT NOT NULL DEFAULT 0 COMMENT '推定已出库件数 = 在库入库 - 现有库存',
|
||||
fifo_plan_json MEDIUMTEXT NULL COMMENT 'FIFO 建议核销明细(JSON,仅前若干条用于展示)',
|
||||
status VARCHAR(16) NOT NULL DEFAULT '待确认' COMMENT '状态:待确认/已确认/已忽略',
|
||||
resolution VARCHAR(20) NULL COMMENT '处理方式:fifo/manual',
|
||||
first_seen_date DATE NOT NULL COMMENT '首次进入差异清单日期',
|
||||
last_seen_date DATE NOT NULL COMMENT '最近一次仍有差异的日期',
|
||||
resolved_by VARCHAR(100) NULL COMMENT '处理人',
|
||||
resolved_at DATETIME NULL COMMENT '处理时间',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_rm_discrepancy_code (warehouse_key, product_code),
|
||||
KEY idx_rm_discrepancy_status (warehouse_key, status, last_seen_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-部分出库差异清单'`,
|
||||
|
||||
// 4. 同步/比对日志
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_job_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
biz_date DATE NOT NULL COMMENT '业务日期',
|
||||
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT '触发方式:manual/cron/api',
|
||||
inbound_rows INT NOT NULL DEFAULT 0 COMMENT '本次同步的入库明细行数',
|
||||
stock_rows INT NOT NULL DEFAULT 0 COMMENT '本次同步的库存行数',
|
||||
cleared_rows INT NOT NULL DEFAULT 0 COMMENT '整批核销(已出库)行数',
|
||||
discrepancy_rows INT NOT NULL DEFAULT 0 COMMENT '差异编码数',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态:success/failed',
|
||||
error_message VARCHAR(1000) NULL COMMENT '错误信息',
|
||||
elapsed_ms INT NOT NULL DEFAULT 0 COMMENT '耗时(ms)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_rm_job_log_date (warehouse_key, biz_date, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-同步与比对日志'`,
|
||||
];
|
||||
|
||||
// 兼容早期建表:把 TEXT 的 fifo_plan_json 放宽为 MEDIUMTEXT
|
||||
const COLUMN_MIGRATIONS = [
|
||||
[
|
||||
'return_monitor_discrepancy',
|
||||
'fifo_plan_json',
|
||||
"ALTER TABLE return_monitor_discrepancy MODIFY COLUMN fifo_plan_json MEDIUMTEXT NULL COMMENT 'FIFO 建议核销明细(JSON,仅前若干条用于展示)'",
|
||||
'mediumtext',
|
||||
],
|
||||
];
|
||||
|
||||
async function applyColumnMigrations() {
|
||||
for (const [table, column, alterSql, expectedType] of COLUMN_MIGRATIONS) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT DATA_TYPE AS d FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
|
||||
[table, column]
|
||||
);
|
||||
const current = String(rows?.[0]?.d || '').toLowerCase();
|
||||
if (current === expectedType) continue;
|
||||
await database.local.query(alterSql);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureReturnMonitorSchema() {
|
||||
for (const sql of SCHEMA_SQL) {
|
||||
await database.local.query(sql);
|
||||
}
|
||||
await applyColumnMigrations();
|
||||
}
|
||||
|
||||
module.exports = { ensureReturnMonitorSchema };
|
||||
1313
backend/src/services/returnMonitorService.js
Normal file
1313
backend/src/services/returnMonitorService.js
Normal file
File diff suppressed because it is too large
Load Diff
144
backend/src/services/returnStockWarehouseRegistry.js
Normal file
144
backend/src/services/returnStockWarehouseRegistry.js
Normal file
@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 客退数据监控 —— 仓库注册表
|
||||
* --------------------------------------------------------------------------
|
||||
* 每个仓库一张「客退数据监控」看板,页面结构完全一致,差异只有三件事:
|
||||
* 1. 库存取自哪个仓(jst_inventoryInfo.wms_co_id);
|
||||
* 2. 「客退临时上架仓」的库存在哪个自定义库存位上;
|
||||
* 3. 退货单表(售后单 / 奇门退款单)里的销退仓名称(仅「最早入库时间」口径用)。
|
||||
*
|
||||
* 关于第 2 点——2026-09-17 用户给出了每个仓在聚水潭里的**准确仓位名**,并明确
|
||||
* 「看板数据取自该仓位」。聚水潭给每个仓库单独开「自定义库存位」来做客退上架仓,
|
||||
* **各仓用的位不一样**(2026-09-16 全库核对结论),所以两个字段要分开写:
|
||||
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
|
||||
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
|
||||
*
|
||||
* 仓位名 → 库存位 对照(2026-09-17 实测,条件 ISNULL(col,0) > 0):
|
||||
* 箱及仓位库存 · 简尚-横林二仓 · 客退临时上架仓 customize_qty_1 1274 编码 / 3271 件
|
||||
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 83 编码 / 317 件
|
||||
* 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 46 编码 / 173 件
|
||||
* 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 324 编码 / 625 件
|
||||
* 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据)
|
||||
*
|
||||
* ⚠ 副本里**没有任何仓位维度**(2026-09-17 复核:218 张表 / 5,496 个字段中,列名含
|
||||
* bin / box / position / location / shelf / slot 的为 0 个;jst_sku.bin、jst_sku.other_bin、
|
||||
* jst_returnAndRefundItem.box_id、jst_packInventoryInfo.bin 四个疑似字段 100% 为空)。
|
||||
* 所以「按仓位名取数」在副本上做不到,只能用该仓位绑定的自定义库存位近似;
|
||||
* 要逐箱还原(含真实的箱上架时间),必须导入聚水潭导出的文件。
|
||||
*
|
||||
* 河南洛宁仓(15539084)当天客退库存为 0:仓库存在、也有退货单,但三个自定义库存位
|
||||
* 全为 0(只有 qty 总库存 83,273 件)。看板照样建,只是空表——页面会提示
|
||||
* 「该仓当前没有客退在库数据」,等聚水潭侧同步进来后自动有内容。
|
||||
*/
|
||||
|
||||
// 客退临时上架仓在 jst_inventoryInfo 里的候选列
|
||||
const RETURN_SLOT_COLUMNS = ['customize_qty_1', 'customize_qty_2', 'customize_qty_3'];
|
||||
|
||||
const WAREHOUSE_LIST = [
|
||||
{
|
||||
key: 'henlin2',
|
||||
// 页面标题 / 侧边栏名称
|
||||
title: '横林二仓客退数据监控',
|
||||
shortName: '横林二仓',
|
||||
// 仓库主表 jst_warehouseInfo.wms_co_id
|
||||
wmsCoId: 15033302,
|
||||
warehouseName: '简尚-横林二仓',
|
||||
// 退货单(售后单 / 奇门退款单)里的销退仓名称,仅 firstInbound 口径用到
|
||||
returnWarehouseDb: '简尚-横林二仓销退仓',
|
||||
// 聚水潭「箱及仓位库存」里对应的仓位名(页面/导出的「数据来源」直接显示它)
|
||||
returnBinName: '客退临时上架仓',
|
||||
// 客退临时上架仓占用的自定义库存位
|
||||
returnSlot: 'customize_qty_1',
|
||||
// 该仓当天是否有客退在库数据(2026-09-17 实测);空仓时页面给提示
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'subei',
|
||||
title: '苏北客退数据监控',
|
||||
shortName: '苏北',
|
||||
wmsCoId: 15038347,
|
||||
warehouseName: '简尚-新苏北仓',
|
||||
returnWarehouseDb: '简尚-新苏北仓销退仓',
|
||||
// 2026-09-17 用户指定:苏北客退数据取自「新苏北客退上架仓」
|
||||
returnBinName: '新苏北客退上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'suining',
|
||||
title: '睢宁客退数据监控',
|
||||
shortName: '睢宁',
|
||||
wmsCoId: 15032603,
|
||||
warehouseName: '简尚-睢宁仓',
|
||||
returnWarehouseDb: '简尚-睢宁仓销退仓',
|
||||
// 2026-09-17 用户指定:睢宁客退数据取自「睢宁客退上架仓」
|
||||
returnBinName: '睢宁客退上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'shandong',
|
||||
title: '山东客退数据监控',
|
||||
shortName: '山东',
|
||||
wmsCoId: 15137523,
|
||||
warehouseName: '简尚-山东青岗集仓',
|
||||
returnWarehouseDb: '简尚-山东青岗集仓销退仓',
|
||||
// 2026-09-17 用户指定:山东客退数据取自「青岗集客退上架仓」
|
||||
returnBinName: '青岗集客退上架仓',
|
||||
// ★ 山东用的是第 2 个自定义库存位,与横林/苏北/睢宁不同
|
||||
returnSlot: 'customize_qty_2',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'henan',
|
||||
title: '河南客退数据监控',
|
||||
shortName: '河南',
|
||||
wmsCoId: 15539084,
|
||||
warehouseName: '简尚-河南洛宁仓',
|
||||
returnWarehouseDb: '简尚-河南洛宁仓销退仓',
|
||||
// 2026-09-17 用户指定:河南客退数据取自「河南洛宁客退上架仓」
|
||||
returnBinName: '河南洛宁客退上架仓',
|
||||
// 该仓三个自定义库存位全为 0,河南洛宁客退上架仓在副本里取不到数据
|
||||
returnSlot: 'customize_qty_1',
|
||||
// 当前该仓客退库存为 0(仓库与退货单都在,只是当天没有客退在库)
|
||||
hasReturnStock: false,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||||
|
||||
const WAREHOUSE_MAP = new Map(WAREHOUSE_LIST.map((item) => [item.key, item]));
|
||||
|
||||
/** 归一化仓库标识:认 key(henlin2 / subei / ...),也容忍直接传 wms_co_id */
|
||||
function resolveWarehouseKey(value) {
|
||||
const raw = String(value === null || value === undefined ? '' : value).trim();
|
||||
if (!raw) return DEFAULT_WAREHOUSE_KEY;
|
||||
const byKey = WAREHOUSE_MAP.get(raw.toLowerCase());
|
||||
if (byKey) return byKey.key;
|
||||
const byId = WAREHOUSE_LIST.find((item) => String(item.wmsCoId) === raw);
|
||||
return byId ? byId.key : DEFAULT_WAREHOUSE_KEY;
|
||||
}
|
||||
|
||||
/** 取仓库配置(永远返回一个有效配置,非法值回落到默认仓) */
|
||||
function resolveWarehouse(value) {
|
||||
return WAREHOUSE_MAP.get(resolveWarehouseKey(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面/导出文案里的「数据来源」,例如
|
||||
* 「箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓」
|
||||
* 仓位名逐仓不同(用户 2026-09-17 口径),必须读注册表,不能写死。
|
||||
*/
|
||||
function getWarehouseStockLabel(warehouse) {
|
||||
return `箱及仓位库存 · ${warehouse.warehouseName} · ${warehouse.returnBinName}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WAREHOUSE_LIST,
|
||||
RETURN_SLOT_COLUMNS,
|
||||
DEFAULT_WAREHOUSE_KEY,
|
||||
resolveWarehouse,
|
||||
resolveWarehouseKey,
|
||||
getWarehouseStockLabel,
|
||||
};
|
||||
275
backend/src/services/returnStockWarningImport.js
Normal file
275
backend/src/services/returnStockWarningImport.js
Normal file
@ -0,0 +1,275 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 聚水潭「箱及仓位库存 - {仓库} - 客退临时上架仓」导出文件导入
|
||||
* --------------------------------------------------------------------------
|
||||
* 用途:副本里没有箱/仓位维度,取不到聚水潭的「创建时间」,件数口径也可能不同。
|
||||
* 导入后按商品编码覆盖「创建时间 / 件数 / 供应商」,页面与导出即与聚水潭一致。
|
||||
*
|
||||
* 按仓库隔离:每个看板(横林二仓 / 苏北 / 睢宁 / 山东 / 河南)各自上传自己仓库的导出文件,
|
||||
* 落在 warehouse_key 下,互不覆盖。调用方通过 warehouseKey 传入。
|
||||
*
|
||||
* 文件要求:xlsx / xls / csv,表头需含「商品编码」(或 sku / 商品编号 / 货号)。
|
||||
* 「创建时间」「数量/件数」「供应商」列名会自动模糊识别,不强制要求。
|
||||
* 同一商品编码出现多行(多个箱/仓位)时:件数求和,创建时间取最早。
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const XLSX = require('xlsx');
|
||||
const database = require('../config/database');
|
||||
|
||||
// 与 returnStockWarehouseRegistry 的默认仓保持一致(不直接 require 以免循环依赖)
|
||||
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||||
|
||||
const MAX_ROWS = 200000;
|
||||
|
||||
const CODE_PATTERN = /(商品编码|商品编号|sku|货号|^编码$)/i;
|
||||
const TIME_PATTERN = /(创建时间|创建日期|上架时间|入库时间|入库日期|首次上架)/i;
|
||||
const QTY_PATTERN = /(件数|数量|库存数量|可用库存|库存)/i;
|
||||
const SUPPLIER_PATTERN = /(供应商|供货商)/i;
|
||||
|
||||
const EXCLUDE_CODE_PATTERN = /(仓位|库位|货位|箱号|仓库)/i;
|
||||
|
||||
function parseDateTimeValue(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
// Excel 序列号:按本地时钟解释,避免出现 +8 小时偏移
|
||||
const days = Math.floor(value);
|
||||
const frac = value - days;
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
const utc = new Date(base + days * 86400000 + Math.round(frac * 86400000));
|
||||
return new Date(
|
||||
utc.getUTCFullYear(), utc.getUTCMonth(), utc.getUTCDate(),
|
||||
utc.getUTCHours(), utc.getUTCMinutes(), utc.getUTCSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
const text = String(value).trim();
|
||||
if (!text) return null;
|
||||
|
||||
const direct = new Date(text.replace(/\//g, '-').replace(/^(\d{4})-(\d{1,2})-(\d{1,2})/, (_, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`));
|
||||
if (!Number.isNaN(direct.getTime())) return direct;
|
||||
|
||||
const nums = text.match(/\d+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
const [y, m, d, hh = '0', mm = '0', ss = '0'] = nums;
|
||||
const built = new Date(Number(y), Number(m) - 1, Number(d), Number(hh), Number(mm), Number(ss));
|
||||
return Number.isNaN(built.getTime()) ? null : built;
|
||||
}
|
||||
|
||||
function parseNumberValue(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
const text = String(value).replace(/[,,\s]/g, '');
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function findHeaderRow(matrix) {
|
||||
for (let i = 0; i < Math.min(matrix.length, 20); i += 1) {
|
||||
const cells = (matrix[i] || []).map((cell) => String(cell === null || cell === undefined ? '' : cell).trim());
|
||||
const hit = cells.some((cell) => CODE_PATTERN.test(cell) && !EXCLUDE_CODE_PATTERN.test(cell));
|
||||
if (hit) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function resolveColumns(headerCells) {
|
||||
const index = {
|
||||
code: -1, time: -1, qty: -1, supplier: -1,
|
||||
};
|
||||
headerCells.forEach((raw, i) => {
|
||||
const cell = String(raw === null || raw === undefined ? '' : raw).trim();
|
||||
if (!cell) return;
|
||||
if (index.code < 0 && CODE_PATTERN.test(cell) && !EXCLUDE_CODE_PATTERN.test(cell)) index.code = i;
|
||||
if (index.time < 0 && TIME_PATTERN.test(cell)) index.time = i;
|
||||
if (index.qty < 0 && QTY_PATTERN.test(cell)) index.qty = i;
|
||||
if (index.supplier < 0 && SUPPLIER_PATTERN.test(cell)) index.supplier = i;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件为「按商品编码聚合」的记录
|
||||
*/
|
||||
function parseImportBuffer(buffer) {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: true });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
throw new Error('文件里没有可读取的工作表');
|
||||
}
|
||||
const matrix = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, defval: null, blankrows: false });
|
||||
if (!matrix.length) {
|
||||
throw new Error('文件是空的');
|
||||
}
|
||||
|
||||
const headerIndex = findHeaderRow(matrix);
|
||||
if (headerIndex < 0) {
|
||||
throw new Error('找不到表头,请确认文件里有一列叫「商品编码」(或 sku / 商品编号 / 货号)');
|
||||
}
|
||||
|
||||
const columns = resolveColumns(matrix[headerIndex] || []);
|
||||
if (columns.code < 0) {
|
||||
throw new Error('找不到「商品编码」列');
|
||||
}
|
||||
|
||||
const aggregated = new Map();
|
||||
let skipped = 0;
|
||||
let withCreatedAt = 0;
|
||||
let withQty = 0;
|
||||
|
||||
for (let i = headerIndex + 1; i < matrix.length && aggregated.size < MAX_ROWS; i += 1) {
|
||||
const row = matrix[i] || [];
|
||||
const code = String(row[columns.code] === null || row[columns.code] === undefined
|
||||
? '' : row[columns.code]).trim();
|
||||
if (!code) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const time = columns.time >= 0 ? parseDateTimeValue(row[columns.time]) : null;
|
||||
const qty = columns.qty >= 0 ? parseNumberValue(row[columns.qty]) : null;
|
||||
const supplier = columns.supplier >= 0
|
||||
? String(row[columns.supplier] === null || row[columns.supplier] === undefined ? '' : row[columns.supplier]).trim()
|
||||
: '';
|
||||
|
||||
const existing = aggregated.get(code);
|
||||
if (!existing) {
|
||||
aggregated.set(code, {
|
||||
productCode: code,
|
||||
createdAt: time,
|
||||
inboundQty: qty === null ? null : qty,
|
||||
supplier,
|
||||
});
|
||||
} else {
|
||||
if (time && (!existing.createdAt || time < existing.createdAt)) existing.createdAt = time;
|
||||
if (qty !== null) {
|
||||
existing.inboundQty = (existing.inboundQty === null ? 0 : existing.inboundQty) + qty;
|
||||
}
|
||||
if (!existing.supplier && supplier) existing.supplier = supplier;
|
||||
}
|
||||
}
|
||||
|
||||
const records = Array.from(aggregated.values());
|
||||
records.forEach((record) => {
|
||||
if (record.createdAt) withCreatedAt += 1;
|
||||
if (record.inboundQty !== null) withQty += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
records,
|
||||
sheetName,
|
||||
headerIndex,
|
||||
columns,
|
||||
totalRows: matrix.length - headerIndex - 1,
|
||||
skipped,
|
||||
withCreatedAt,
|
||||
withQty,
|
||||
};
|
||||
}
|
||||
|
||||
function toMysqlDateTime(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
||||
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入导入数据(按仓库整批替换:以该仓最新一次导出为准)
|
||||
* 只删/写当前仓库的行,不会动其它看板的导入数据。
|
||||
*/
|
||||
async function importStockWarning({ buffer, fileName, importedBy, warehouseKey }) {
|
||||
const parsed = parseImportBuffer(buffer);
|
||||
if (!parsed.records.length) {
|
||||
throw new Error('没有解析到任何有效的商品编码行');
|
||||
}
|
||||
|
||||
const key = String(warehouseKey || DEFAULT_WAREHOUSE_KEY).trim() || DEFAULT_WAREHOUSE_KEY;
|
||||
const batchId = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
await database.local.query(
|
||||
'DELETE FROM return_stock_warning_import WHERE warehouse_key = ?',
|
||||
[key]
|
||||
);
|
||||
|
||||
const chunkSize = 500;
|
||||
for (let i = 0; i < parsed.records.length; i += chunkSize) {
|
||||
const chunk = parsed.records.slice(i, i + chunkSize);
|
||||
const values = chunk.map((record) => ([
|
||||
key,
|
||||
record.productCode,
|
||||
toMysqlDateTime(record.createdAt),
|
||||
record.inboundQty,
|
||||
record.supplier || null,
|
||||
batchId,
|
||||
]));
|
||||
await database.local.query(
|
||||
`INSERT INTO return_stock_warning_import
|
||||
(warehouse_key, product_code, created_at, inbound_qty, supplier, batch_id)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
created_at = VALUES(created_at),
|
||||
inbound_qty = VALUES(inbound_qty),
|
||||
supplier = VALUES(supplier),
|
||||
batch_id = VALUES(batch_id)`,
|
||||
[values]
|
||||
);
|
||||
}
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO return_stock_warning_import_batch
|
||||
(batch_id, warehouse_key, file_name, total_rows, matched_rows, with_created_at, with_qty, skipped_rows, imported_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
batchId,
|
||||
key,
|
||||
String(fileName || '').slice(0, 255),
|
||||
parsed.totalRows,
|
||||
parsed.records.length,
|
||||
parsed.withCreatedAt,
|
||||
parsed.withQty,
|
||||
parsed.skipped,
|
||||
importedBy || null,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
batchId,
|
||||
warehouseKey: key,
|
||||
fileName: String(fileName || ''),
|
||||
sheetName: parsed.sheetName,
|
||||
totalRows: parsed.totalRows,
|
||||
matchedRows: parsed.records.length,
|
||||
withCreatedAt: parsed.withCreatedAt,
|
||||
withQty: parsed.withQty,
|
||||
skippedRows: parsed.skipped,
|
||||
detectedColumns: {
|
||||
code: parsed.columns.code,
|
||||
time: parsed.columns.time,
|
||||
qty: parsed.columns.qty,
|
||||
supplier: parsed.columns.supplier,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 清除当前仓库的导入,回到「系统推算」口径(不影响其它看板) */
|
||||
async function clearStockWarningImport(warehouseKey) {
|
||||
const key = String(warehouseKey || DEFAULT_WAREHOUSE_KEY).trim() || DEFAULT_WAREHOUSE_KEY;
|
||||
await database.local.query('DELETE FROM return_stock_warning_import WHERE warehouse_key = ?', [key]);
|
||||
await database.local.query('DELETE FROM return_stock_warning_import_batch WHERE warehouse_key = ?', [key]);
|
||||
return { cleared: true, warehouseKey: key };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importStockWarning,
|
||||
clearStockWarningImport,
|
||||
parseImportBuffer,
|
||||
};
|
||||
130
backend/src/services/returnStockWarningSchema.js
Normal file
130
backend/src/services/returnStockWarningSchema.js
Normal file
@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 退货库存预警(客退数据监控)本地表
|
||||
* --------------------------------------------------------------------------
|
||||
* 为什么需要本地表:
|
||||
* 报表副本(JSReportReplica)只有仓库级库存表 jst_inventoryInfo,
|
||||
* 没有聚水潭「箱及仓位库存」的箱/仓位维度,因此:
|
||||
* - 「创建时间」= 聚水潭该箱/仓位记录的上架时间,副本里根本没有这个字段;
|
||||
* - 「件数」也可能与聚水潭口径不同(副本只能按 return_qty + 客退自定义库存位近似)。
|
||||
* 要跟聚水潭页面 100% 对齐,唯一办法是把聚水潭导出的「箱及仓位库存」导入进来,
|
||||
* 按商品编码覆盖创建时间(可选覆盖件数),并持久化。
|
||||
*
|
||||
* 按仓库隔离(2026-09-16 新增):
|
||||
* 现在有横林二仓 / 苏北 / 睢宁 / 山东 / 河南 五张看板,每张上传的是各自仓库的聚水潭导出文件,
|
||||
* 同一份文件里同一个商品编码可能同时出现在多个仓;如果不按仓隔离,后导入的会把先导入的覆盖掉。
|
||||
* 因此两张表都加了 warehouse_key,主键从 (product_code) 改成 (warehouse_key, product_code)。
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||||
|
||||
const SCHEMA_SQL = [
|
||||
// 1. 按「仓库 + 商品编码」的导入覆盖值(以聚水潭导出为准)
|
||||
`CREATE TABLE IF NOT EXISTS return_stock_warning_import (
|
||||
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识(henlin2/subei/suining/shandong/henan)',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||||
created_at DATETIME NULL COMMENT '聚水潭创建时间(上架时间)',
|
||||
inbound_qty INT NULL COMMENT '聚水潭件数(导入,可为空=不覆盖)',
|
||||
supplier VARCHAR(255) NULL COMMENT '聚水潭供应商(导入,可为空)',
|
||||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (warehouse_key, product_code),
|
||||
KEY idx_rswi_batch (batch_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入覆盖值(按仓库+商品编码)'`,
|
||||
|
||||
// 2. 导入批次日志
|
||||
`CREATE TABLE IF NOT EXISTS return_stock_warning_import_batch (
|
||||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||||
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识',
|
||||
file_name VARCHAR(255) NULL COMMENT '文件名',
|
||||
total_rows INT NOT NULL DEFAULT 0 COMMENT '解析出的行数',
|
||||
matched_rows INT NOT NULL DEFAULT 0 COMMENT '成功写入的编码数',
|
||||
with_created_at INT NOT NULL DEFAULT 0 COMMENT '含创建时间的行数',
|
||||
with_qty INT NOT NULL DEFAULT 0 COMMENT '含件数的行数',
|
||||
skipped_rows INT NOT NULL DEFAULT 0 COMMENT '无商品编码被跳过的行数',
|
||||
imported_by VARCHAR(100) NULL COMMENT '导入人',
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||||
PRIMARY KEY (batch_id),
|
||||
KEY idx_rswibatch_time (imported_at),
|
||||
KEY idx_rswibatch_wh (warehouse_key, imported_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入批次日志'`,
|
||||
];
|
||||
|
||||
/** 某张表上是否存在某列 */
|
||||
async function hasColumn(table, column) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||
[table, column]
|
||||
);
|
||||
return Number(rows?.[0]?.cnt || 0) > 0;
|
||||
}
|
||||
|
||||
/** 某张表的主键列(按顺序) */
|
||||
async function getPrimaryKeyColumns(table) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
||||
ORDER BY ORDINAL_POSITION`,
|
||||
[table]
|
||||
);
|
||||
return (rows || []).map((row) => String(row.COLUMN_NAME || '').toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 老库升级:给已存在的两张表补 warehouse_key,并把导入表主键改成 (warehouse_key, product_code)。
|
||||
* 幂等,每次启动都跑一遍,已经是新结构时不做任何事。
|
||||
*/
|
||||
async function migrateReturnStockWarningSchema() {
|
||||
// 逐列补:两张表都要有 warehouse_key
|
||||
const targets = ['return_stock_warning_import', 'return_stock_warning_import_batch'];
|
||||
for (const table of targets) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await hasColumn(table, 'warehouse_key'))) {
|
||||
// 老数据全部归到默认仓(横林二仓)——它本来就是为横林二仓建的
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await database.local.query(
|
||||
`ALTER TABLE ${table}
|
||||
ADD COLUMN warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}'
|
||||
COMMENT '仓库标识' AFTER ${table === 'return_stock_warning_import' ? 'product_code' : 'batch_id'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 主键改造:老结构是 (product_code),需要换成 (warehouse_key, product_code)
|
||||
const pk = await getPrimaryKeyColumns('return_stock_warning_import');
|
||||
if (pk.length === 1 && pk[0] === 'PRODUCT_CODE') {
|
||||
await database.local.query(
|
||||
'ALTER TABLE return_stock_warning_import DROP PRIMARY KEY, ADD PRIMARY KEY (warehouse_key, product_code)'
|
||||
);
|
||||
}
|
||||
|
||||
// 批次表的仓库索引
|
||||
const idxRows = await database.local.query(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'return_stock_warning_import_batch'
|
||||
AND INDEX_NAME = 'idx_rswibatch_wh'`
|
||||
);
|
||||
if (Number(idxRows?.[0]?.cnt || 0) === 0) {
|
||||
await database.local.query(
|
||||
'ALTER TABLE return_stock_warning_import_batch ADD KEY idx_rswibatch_wh (warehouse_key, imported_at)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureReturnStockWarningSchema() {
|
||||
for (const sql of SCHEMA_SQL) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await database.local.query(sql);
|
||||
}
|
||||
await migrateReturnStockWarningSchema();
|
||||
}
|
||||
|
||||
module.exports = { ensureReturnStockWarningSchema, migrateReturnStockWarningSchema };
|
||||
1245
backend/src/services/returnStockWarningService.js
Normal file
1245
backend/src/services/returnStockWarningService.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,56 @@ const request = axios.create({
|
||||
});
|
||||
const AUTH_USER_STORAGE_KEY = 'auth_user';
|
||||
|
||||
// 后端重启窗口内的自动重连
|
||||
// ------------------------------------------------------------------
|
||||
// 本地/线上后端在「隧道掉线 -> 监督器重启后端」期间会有 20~90 秒不可用,
|
||||
// 此时 Vite 代理拿不到后端,会直接回 500(响应体是 HTML,没有 error 字段),
|
||||
// 用户看到的就是一句没头没尾的 "Request failed with status code 500"。
|
||||
// 这里对**幂等请求**(GET/HEAD/OPTIONS)在网络错误或 5xx 时做退避重试,
|
||||
// 重启窗口过去后请求会自己成功,页面不再报错。
|
||||
// 累计等待约 80 秒,覆盖最坏情况下的后端启动耗时。
|
||||
const RETRY_BACKOFF_MS = [1500, 3000, 5000, 8000, 13000, 21000, 30000];
|
||||
const RETRYABLE_METHODS = ['get', 'head', 'options'];
|
||||
const RETRY_NOTICE_KEY = 'api-reconnect-notice';
|
||||
|
||||
function isRetryableRequest(config) {
|
||||
if (!config || config.retry === false) {
|
||||
return false;
|
||||
}
|
||||
const method = String(config.method || 'get').toLowerCase();
|
||||
return RETRYABLE_METHODS.includes(method);
|
||||
}
|
||||
|
||||
function isRetryableError(error) {
|
||||
if (axios.isCancel?.(error) || error?.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
const status = error?.response?.status;
|
||||
// 无响应:网络中断、连接被拒(后端还没起来)、代理转发失败
|
||||
if (!status) {
|
||||
return true;
|
||||
}
|
||||
// 5xx:后端内部错误 / 网关与代理错误
|
||||
return status >= 500 && status <= 599;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function notifyReconnecting() {
|
||||
message.open({
|
||||
key: RETRY_NOTICE_KEY,
|
||||
type: 'loading',
|
||||
content: '服务正在重启,正在自动重连…',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function clearReconnectNotice() {
|
||||
message.destroy(RETRY_NOTICE_KEY);
|
||||
}
|
||||
|
||||
function clearAuthSession() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
|
||||
@ -94,11 +144,34 @@ request.interceptors.response.use(
|
||||
}
|
||||
return Promise.reject(new Error(res.message || 'Error'));
|
||||
}
|
||||
clearReconnectNotice();
|
||||
return res;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const config = error?.config;
|
||||
|
||||
// ---- 后端重启窗口内自动重试(只对幂等请求、只对网络错误/5xx)----
|
||||
// 说明:这里用 `return request(config)`(不带 await)而不是 await,
|
||||
// 这样重试失败时由「内层」这一分支继续退避;外层调用栈不会重新进入本函数,
|
||||
// 因此最终失败提示天然只会出现一次,也不会把重试次数叠加错。
|
||||
if (isRetryableRequest(config) && !config.__retryExhausted && isRetryableError(error)) {
|
||||
const attempt = Number(config.__retryAttempt || 0);
|
||||
if (attempt < RETRY_BACKOFF_MS.length) {
|
||||
config.__retryAttempt = attempt + 1;
|
||||
if (attempt === 0 && !config.silentError) {
|
||||
notifyReconnecting();
|
||||
}
|
||||
await sleep(RETRY_BACKOFF_MS[attempt]);
|
||||
return request(config);
|
||||
}
|
||||
// 次数用尽:标记后不再重试,并把连接提示收掉
|
||||
config.__retryExhausted = true;
|
||||
config.__retryAttempt = 0;
|
||||
clearReconnectNotice();
|
||||
}
|
||||
|
||||
const authSessionFailure = isAuthSessionFailure(error.response);
|
||||
if (!authSessionFailure && !error.config?.silentError) {
|
||||
if (!authSessionFailure && !error?.config?.silentError) {
|
||||
message.error(getRequestErrorMessage(error));
|
||||
}
|
||||
if (authSessionFailure && !shouldBypass401Redirect(error)) {
|
||||
|
||||
48
frontend/src/api/returnMonitor.js
Normal file
48
frontend/src/api/returnMonitor.js
Normal file
@ -0,0 +1,48 @@
|
||||
import request from './request';
|
||||
|
||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// 横林二仓客退:退货入库核销 + 每日库存监控看板
|
||||
export const getReturnMonitorDashboard = (params = {}) => request.get(
|
||||
'/api/return-monitor/dashboard',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 手动触发一轮「同步 + 每日比对」
|
||||
export const syncReturnMonitor = (payload = {}) => request.post(
|
||||
'/api/return-monitor/sync',
|
||||
payload,
|
||||
{ timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 某商品编码当前的全部在库入库行(差异清单「手动指定」勾选用)
|
||||
export const getReturnMonitorCodeLines = (params = {}) => request.get(
|
||||
'/api/return-monitor/code-lines',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 差异清单处理:mode = fifo | manual
|
||||
export const resolveReturnMonitorDiscrepancy = (payload = {}) => request.post(
|
||||
'/api/return-monitor/discrepancy/resolve',
|
||||
payload,
|
||||
{ timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 人工标记单行状态:在库 / 已出库 / 已报废
|
||||
export const markReturnMonitorInbound = (payload = {}) => request.post(
|
||||
'/api/return-monitor/inbound/mark',
|
||||
payload,
|
||||
{ timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出看板数据(scope=all|detail|overdue|code|discrepancy|age,返回 xlsx / csv 二进制)
|
||||
// silentError:错误体是 Blob,由调用方用 readBlobErrorMessage 解出可读文案后再提示
|
||||
export const exportReturnMonitor = (params = {}) => request.get(
|
||||
'/api/return-monitor/export',
|
||||
{
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
silentError: true,
|
||||
}
|
||||
);
|
||||
39
frontend/src/api/returnStockWarning.js
Normal file
39
frontend/src/api/returnStockWarning.js
Normal file
@ -0,0 +1,39 @@
|
||||
import request from './request';
|
||||
|
||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// 客退数据监控(箱及仓位库存 · 客退临时上架仓)
|
||||
// params.warehouse: henlin2 / subei / suining / shandong / henan(缺省 = 横林二仓)
|
||||
export const getReturnStockWarningList = (params = {}) => request.get(
|
||||
'/api/return-stock-warning/list',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出(xlsx / csv,服务端全量生成,返回二进制)
|
||||
export const exportReturnStockWarning = (params = {}) => request.get(
|
||||
'/api/return-stock-warning/export',
|
||||
{
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
silentError: true,
|
||||
}
|
||||
);
|
||||
|
||||
// 导入聚水潭「箱及仓位库存」导出文件(xlsx / xls / csv)
|
||||
// warehouse: 导入落到哪个仓库下,各仓的导入互不覆盖
|
||||
export const importReturnStockWarning = (file, warehouse) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (warehouse) formData.append('warehouse', warehouse);
|
||||
return request.post('/api/return-stock-warning/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
});
|
||||
};
|
||||
|
||||
// 清除当前仓库的导入,回到「系统推算」口径(不影响其它仓)
|
||||
export const clearReturnStockWarningImport = (warehouse) => request.delete(
|
||||
'/api/return-stock-warning/import',
|
||||
{ params: warehouse ? { warehouse } : {} }
|
||||
);
|
||||
@ -18,6 +18,16 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'returns-inventory', type: 'group', label: '退货库存监控', icon: 'inbox', visible: true, order: 75, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'returns-inventory-henlin2', label: '横林二仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-henlin2', visible: true, order: 10 },
|
||||
{ key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 },
|
||||
{ key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 },
|
||||
{ key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 },
|
||||
{ key: 'returns-inventory-henan', label: '河南客退数据监控', icon: 'table', routeKey: 'returns-inventory-henan', visible: true, order: 50 },
|
||||
],
|
||||
},
|
||||
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
|
||||
{
|
||||
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
DownOutlined,
|
||||
ExperimentOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
InboxOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
@ -60,6 +61,7 @@ const SIDEBAR_ICON_MAP = {
|
||||
dashboard: DashboardOutlined,
|
||||
desktop: DesktopOutlined,
|
||||
experiment: ExperimentOutlined,
|
||||
inbox: InboxOutlined,
|
||||
'line-chart': LineChartOutlined,
|
||||
message: MessageOutlined,
|
||||
projection: FundProjectionScreenOutlined,
|
||||
@ -79,6 +81,31 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
},
|
||||
'promotion-module': { path: '/planning/operation/promotion-module' },
|
||||
'product-monitoring': { path: '/planning/operation/product-monitoring' },
|
||||
'returns-inventory-henlin2': {
|
||||
path: '/planning/operation/returns-inventory/henlin2',
|
||||
activePath: '/planning/operation/returns-inventory/henlin2',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-subei': {
|
||||
path: '/planning/operation/returns-inventory/subei',
|
||||
activePath: '/planning/operation/returns-inventory/subei',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-suining': {
|
||||
path: '/planning/operation/returns-inventory/suining',
|
||||
activePath: '/planning/operation/returns-inventory/suining',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-shandong': {
|
||||
path: '/planning/operation/returns-inventory/shandong',
|
||||
activePath: '/planning/operation/returns-inventory/shandong',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-henan': {
|
||||
path: '/planning/operation/returns-inventory/henan',
|
||||
activePath: '/planning/operation/returns-inventory/henan',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
|
||||
'product-analysis': { path: '/planning/operation/product-analysis' },
|
||||
'work-orders': { path: '/planning/operation/work-orders' },
|
||||
@ -101,7 +128,6 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
|
||||
if (entry.requiresAdmin && !canUseSettings) return [];
|
||||
if (entry.type === 'section') return [{ ...entry }];
|
||||
|
||||
const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {};
|
||||
const routeMatches = (itemRoute = {}, pathname = '') => {
|
||||
const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : [];
|
||||
if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
|
||||
@ -119,9 +145,16 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
|
||||
? pathname === matchPath || pathname.startsWith(`${matchPath}/`)
|
||||
: pathname === matchPath;
|
||||
};
|
||||
|
||||
// 递归构建菜单节点:支持任意层级(分组 -> 子分组 -> 叶子)
|
||||
const buildItem = (item) => {
|
||||
if (!item) return null;
|
||||
if (item.requiresAdmin && !canUseSettings) return null;
|
||||
const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {};
|
||||
const rawChildren = Array.isArray(item.children) ? item.children : null;
|
||||
const children = rawChildren
|
||||
? sortSidebarEntries(rawChildren).map((child) => buildItem(child)).filter(Boolean)
|
||||
: undefined;
|
||||
return {
|
||||
...item,
|
||||
icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined,
|
||||
@ -129,25 +162,88 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
|
||||
external: Boolean(itemRoute.externalUrl),
|
||||
onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined,
|
||||
match: (path) => routeMatches(itemRoute, path),
|
||||
...(children && children.length ? { children } : (rawChildren ? { children: [] } : {})),
|
||||
};
|
||||
};
|
||||
|
||||
const normalized = {
|
||||
...entry,
|
||||
icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined,
|
||||
path: route.path,
|
||||
external: Boolean(route.externalUrl),
|
||||
onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined,
|
||||
match: (path) => routeMatches(route, path),
|
||||
};
|
||||
|
||||
if (entry.type === 'group') {
|
||||
normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean);
|
||||
}
|
||||
|
||||
const normalized = buildItem(entry);
|
||||
if (!normalized) return [];
|
||||
normalized.type = entry.type;
|
||||
return [normalized];
|
||||
});
|
||||
|
||||
// 递归收集默认展开的菜单 key(支持多级)
|
||||
const collectDefaultExpandedKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
...(entry?.defaultExpanded && entry?.key ? [entry.key] : []),
|
||||
...collectDefaultExpandedKeys(entry?.children || []),
|
||||
]);
|
||||
|
||||
// 递归判断菜单项(含其子孙)是否命中当前路径
|
||||
const isNavEntryActive = (entry, pathname) => {
|
||||
if (!entry) return false;
|
||||
if (entry.match?.(pathname)) return true;
|
||||
return Array.isArray(entry.children) && entry.children.some((child) => isNavEntryActive(child, pathname));
|
||||
};
|
||||
|
||||
// 侧边栏菜单节点(递归渲染,支持三级及以上)
|
||||
const SidebarNavNode = ({
|
||||
item, level, pathname, expandedKeys, onToggle, onLeaf, collapsed,
|
||||
}) => {
|
||||
const Icon = item.icon || AppstoreOutlined;
|
||||
const expandable = Array.isArray(item.children) && item.children.length > 0;
|
||||
const descendantActive = expandable && item.children.some((child) => isNavEntryActive(child, pathname));
|
||||
const selfActive = Boolean(item.match?.(pathname));
|
||||
const active = selfActive || (collapsed && descendantActive);
|
||||
const expanded = expandable && expandedKeys.includes(item.key) && !collapsed;
|
||||
const isTop = level <= 1;
|
||||
const baseClass = isTop ? 'planning-subsystem-nav-item' : 'planning-subsystem-subnav-item';
|
||||
const iconClass = isTop ? 'planning-subsystem-nav-icon' : 'planning-subsystem-subnav-icon';
|
||||
|
||||
return (
|
||||
<div className="planning-subsystem-nav-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseClass}${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
|
||||
style={isTop ? undefined : { paddingLeft: 36 + (level - 2) * 20 }}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
onToggle(item.key);
|
||||
return;
|
||||
}
|
||||
onLeaf(item);
|
||||
}}
|
||||
title={item.label}
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
>
|
||||
<Icon className={iconClass} />
|
||||
<span className={isTop ? 'planning-subsystem-nav-label' : 'planning-subsystem-subnav-label'}>{item.label}</span>
|
||||
{expandable ? (
|
||||
<span className="planning-subsystem-nav-chevron">
|
||||
{expanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{expandable && expanded ? (
|
||||
<div className="planning-subsystem-subnav">
|
||||
{item.children.map((child) => (
|
||||
<SidebarNavNode
|
||||
key={child.key}
|
||||
item={child}
|
||||
level={level + 1}
|
||||
pathname={pathname}
|
||||
expandedKeys={expandedKeys}
|
||||
onToggle={onToggle}
|
||||
onLeaf={onLeaf}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function parseJwtPayload(token) {
|
||||
if (!token) {
|
||||
return null;
|
||||
@ -292,9 +388,7 @@ const BasicLayout = () => {
|
||||
setSidebarConfig(nextSidebarConfig);
|
||||
setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault));
|
||||
setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault));
|
||||
setExpandedSidebarKeys(
|
||||
nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key)
|
||||
);
|
||||
setExpandedSidebarKeys(collectDefaultExpandedKeys(nextSidebarConfig.entries));
|
||||
document.title = nextSiteName;
|
||||
|
||||
let link = document.querySelector("link[rel*='icon']");
|
||||
@ -504,12 +598,6 @@ const BasicLayout = () => {
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
const isSidebarEntryActive = (entry) => {
|
||||
if (!entry) return false;
|
||||
if (entry.match?.(pathname)) return true;
|
||||
return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child));
|
||||
};
|
||||
|
||||
const toggleSidebarGroup = (key) => {
|
||||
setSidebarCollapsed(false);
|
||||
setExpandedSidebarKeys((keys) => (
|
||||
@ -567,58 +655,17 @@ const BasicLayout = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = item.icon;
|
||||
const expandable = Array.isArray(item.children) && item.children.length > 0;
|
||||
const descendantActive = expandable && item.children.some((child) => isSidebarEntryActive(child));
|
||||
const selfActive = Boolean(item.match?.(pathname));
|
||||
const active = selfActive || (sidebarCollapsed && descendantActive);
|
||||
const expanded = expandable && expandedSidebarKeys.includes(item.key) && !sidebarCollapsed;
|
||||
|
||||
return (
|
||||
<div key={item.key} className={`planning-subsystem-nav-group${expanded ? ' is-expanded' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`planning-subsystem-nav-item${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
toggleSidebarGroup(item.key);
|
||||
return;
|
||||
}
|
||||
handleSidebarLeafClick(item);
|
||||
}}
|
||||
title={item.label}
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
>
|
||||
<Icon className="planning-subsystem-nav-icon" />
|
||||
<span className="planning-subsystem-nav-label">{item.label}</span>
|
||||
{expandable ? (
|
||||
<span className="planning-subsystem-nav-chevron">
|
||||
{expanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{expandable && expanded ? (
|
||||
<div className="planning-subsystem-subnav">
|
||||
{item.children.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = isSidebarEntryActive(child);
|
||||
return (
|
||||
<button
|
||||
key={child.key}
|
||||
type="button"
|
||||
className={`planning-subsystem-subnav-item${childActive ? ' is-active' : ''}`}
|
||||
onClick={() => handleSidebarLeafClick(child)}
|
||||
title={child.label}
|
||||
>
|
||||
<ChildIcon className="planning-subsystem-subnav-icon" />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SidebarNavNode
|
||||
key={item.key}
|
||||
item={item}
|
||||
level={1}
|
||||
pathname={pathname}
|
||||
expandedKeys={expandedSidebarKeys}
|
||||
onToggle={toggleSidebarGroup}
|
||||
onLeaf={handleSidebarLeafClick}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@ -976,6 +1023,22 @@ const BasicLayout = () => {
|
||||
background: var(--sidebar-active-bg);
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-item.is-descendant-active:not(.is-active) {
|
||||
color: var(--sidebar-active-text);
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-item.is-expandable .planning-subsystem-nav-chevron {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-icon {
|
||||
flex: 0 0 14px;
|
||||
color: var(--sidebar-icon-muted);
|
||||
|
||||
51
frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx
Normal file
51
frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import StockWarning from './StockWarning';
|
||||
import { resolveWarehouseConfig } from './warehouses';
|
||||
import './monitor.css';
|
||||
|
||||
/**
|
||||
* 客退数据监控(按仓库出数)
|
||||
*
|
||||
* 同一个页面支撑 5 张看板:横林二仓 / 苏北 / 睢宁 / 山东 / 河南。
|
||||
* 结构完全一致,差异只有「取哪个仓」——由路由传入的 warehouseKey 决定,
|
||||
* 页面本身不拼任何仓库相关的 SQL 或口径。
|
||||
*
|
||||
* 页面已收敛为单一「退货库存预警」视图:
|
||||
* 原先并列的「退货入库明细 / 超期清单 / 按商品编码汇总 / 差异清单」四个页签已下线,
|
||||
* 因此这里不再保留页签容器,也不再拉取核销看板数据(对应接口保留在服务端,未删除)。
|
||||
*/
|
||||
const Monitor = ({ warehouseKey }) => {
|
||||
const warehouse = resolveWarehouseConfig(warehouseKey);
|
||||
|
||||
return (
|
||||
<SycmPage className="rim-page">
|
||||
<div className="rim-hero">
|
||||
<div>
|
||||
<h1 className="rim-hero__title">{warehouse.title}</h1>
|
||||
<p className="rim-hero__desc">
|
||||
数据取自聚水潭「箱及仓位库存 · {warehouse.warehouseName} · {warehouse.returnBinName}」:
|
||||
在库天数 = 今天 − 创建时间,超过预警天数标红。
|
||||
创建时间、件数口径与退厂判定说明见页面底部「数据来源与口径说明」。
|
||||
</p>
|
||||
{warehouse.hasReturnStock && warehouse.returnSlot !== 'customize_qty_1' ? (
|
||||
<p className="rim-hero__desc" style={{ marginTop: 4 }}>
|
||||
注:该仓位在业务库里占用的是 <b>{warehouse.returnSlot}</b>
|
||||
(横林二仓 / 苏北 / 睢宁用的是 customize_qty_1),页面已按这个位取数。
|
||||
</p>
|
||||
) : null}
|
||||
{!warehouse.hasReturnStock ? (
|
||||
<p className="rim-hero__desc" style={{ marginTop: 4 }}>
|
||||
提示:该仓位当前在业务库副本里没有客退在库数据(三个自定义库存位全为 0),表格会是空的;
|
||||
等聚水潭侧有客退库存同步进来后会自动出现内容,也可以用「导入」上传聚水潭导出的文件。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockWarning warehouse={warehouse.key} />
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitor;
|
||||
1067
frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx
Normal file
1067
frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,485 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty, InputNumber, Tooltip } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { sycmEchartsBase } from '../../design-system/chartTheme';
|
||||
|
||||
/**
|
||||
* 客退数据监控 · 可视化分析(折叠区内容)
|
||||
*
|
||||
* 只负责「看」:单价预警阈值 + 可点击的单价预警汇总条 + 4 张分布图。
|
||||
* 数据全部由父组件(StockWarning.jsx)传进来,这里不做任何请求。
|
||||
*
|
||||
* 页面的指标卡与「退厂要求」提示条已上移到父组件的主视图里,
|
||||
* 这里保留的都是在默认视图下折叠收起的内容,保证主视图足够干净。
|
||||
*/
|
||||
|
||||
const num = (value) => Number(value || 0).toLocaleString('zh-CN');
|
||||
const money = (value) => Number(value || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
/** 图表轴上的金额压缩显示:过万折成「万」,避免坐标轴被长数字撑爆 */
|
||||
const compactMoney = (value) => {
|
||||
const parsed = Number(value || 0);
|
||||
if (Math.abs(parsed) >= 10000) return `${(parsed / 10000).toFixed(1)}万`;
|
||||
return parsed.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
// 单价预警四挡(与后端 returnStockWarningService.PRICE_WARNING_LABELS 保持一致)
|
||||
const PRICE_ORDER = ['high', 'focus', 'normal', 'unknown'];
|
||||
const PRICE_TONES = {
|
||||
high: { label: '重点关注', short: '重点关注', color: '#ff4d4f', tone: 'red', dot: '#ff4d4f' },
|
||||
focus: { label: '关注', short: '关注', color: '#ec6c00', tone: 'orange', dot: '#ec6c00' },
|
||||
normal: { label: '正常', short: '正常', color: '#1bb574', tone: 'green', dot: '#1bb574' },
|
||||
unknown: { label: '无单价', short: '无单价', color: '#94a3b8', tone: 'grey', dot: '#94a3b8' },
|
||||
};
|
||||
|
||||
// 图表基座:pie 不需要 grid / xAxis / yAxis,单独取一份干净的基础配置
|
||||
const pieBase = {
|
||||
color: sycmEchartsBase.color,
|
||||
textStyle: sycmEchartsBase.textStyle,
|
||||
tooltip: sycmEchartsBase.tooltip,
|
||||
legend: sycmEchartsBase.legend,
|
||||
};
|
||||
|
||||
const chartHeight = 236;
|
||||
|
||||
/** 库龄分档:以预警线为基准逐级放大,阈值改了分档标签跟着变,不会出现空档 */
|
||||
const buildAgeTiers = (focusDays, alertDays) => {
|
||||
const safeAlert = Math.max(alertDays, focusDays + 1);
|
||||
return [
|
||||
{ label: `0 ~ ${focusDays} 天`, from: 0, to: focusDays, color: '#1bb574', hint: '库龄正常' },
|
||||
{ label: `${focusDays + 1} ~ ${safeAlert} 天`, from: focusDays + 1, to: safeAlert, color: '#ec6c00', hint: '库龄关注' },
|
||||
{ label: `${safeAlert + 1} ~ ${safeAlert * 2} 天`, from: safeAlert + 1, to: safeAlert * 2, color: '#ff7a45', hint: '红色预警' },
|
||||
{ label: `${safeAlert * 2 + 1} ~ ${safeAlert * 4} 天`, from: safeAlert * 2 + 1, to: safeAlert * 4, color: '#ff4d4f', hint: '红色预警' },
|
||||
{ label: `> ${safeAlert * 4} 天`, from: safeAlert * 4 + 1, to: Infinity, color: '#a8071a', hint: '红色预警·严重' },
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* 指标卡(父组件主视图也用,因此具名导出)
|
||||
* 白底 + 左侧色条 + 大数字,和商品明细页的卡片同一种视觉语言
|
||||
*/
|
||||
export const MetricCard = ({ tone, label, value, unit, foot, tip }) => (
|
||||
<div className="rim-metric" data-tone={tone}>
|
||||
<div className="rim-metric__head">
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="rim-metric__value">
|
||||
{value}
|
||||
{unit ? <span className="rim-metric__unit">{unit}</span> : null}
|
||||
</div>
|
||||
<div className="rim-metric__foot">
|
||||
{tip ? <Tooltip title={tip}><span>{foot}</span></Tooltip> : foot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StockWarningDashboard = ({
|
||||
rows = [],
|
||||
thresholds = {},
|
||||
priceThresholds = {},
|
||||
onPriceFocusChange,
|
||||
onPriceHighChange,
|
||||
priceLevel = 'all',
|
||||
onPriceLevelToggle,
|
||||
returnLimitDays = 7,
|
||||
onReturnLimitChange,
|
||||
loading = false,
|
||||
}) => {
|
||||
const focusDays = Number(thresholds.focusDays || 0);
|
||||
const alertDays = Number(thresholds.alertDays || 0);
|
||||
const priceFocusMin = Number(priceThresholds.priceFocusMin || 0);
|
||||
const priceHighMin = Number(priceThresholds.priceHighMin || 0);
|
||||
const returnLimit = Number(returnLimitDays || 0);
|
||||
|
||||
// ── 库龄分档统计 ───────────────────────────────────────────
|
||||
const ageStats = useMemo(() => {
|
||||
const tiers = buildAgeTiers(focusDays, alertDays).map((tier) => ({
|
||||
...tier, codes: 0, qty: 0, amount: 0,
|
||||
}));
|
||||
let unknownCodes = 0;
|
||||
(rows || []).forEach((row) => {
|
||||
const days = row.stockDays;
|
||||
if (days === null || days === undefined) {
|
||||
unknownCodes += 1;
|
||||
return;
|
||||
}
|
||||
const tier = tiers.find((item) => days >= item.from && days <= item.to);
|
||||
if (!tier) return;
|
||||
tier.codes += 1;
|
||||
tier.qty += Number(row.inboundQty || 0);
|
||||
tier.amount += Number(row.amount || 0);
|
||||
});
|
||||
return { tiers, unknownCodes };
|
||||
}, [rows, focusDays, alertDays]);
|
||||
|
||||
// ── 单价预警统计 ───────────────────────────────────────────
|
||||
const priceStats = useMemo(() => {
|
||||
const stats = {
|
||||
high: { codes: 0, qty: 0, amount: 0 },
|
||||
focus: { codes: 0, qty: 0, amount: 0 },
|
||||
normal: { codes: 0, qty: 0, amount: 0 },
|
||||
unknown: { codes: 0, qty: 0, amount: 0 },
|
||||
};
|
||||
(rows || []).forEach((row) => {
|
||||
const level = PRICE_TONES[row.unitPriceLevel] ? row.unitPriceLevel : 'unknown';
|
||||
stats[level].codes += 1;
|
||||
stats[level].qty += Number(row.inboundQty || 0);
|
||||
stats[level].amount += Number(row.amount || 0);
|
||||
});
|
||||
return stats;
|
||||
}, [rows]);
|
||||
|
||||
// ── 供应商 / 编码 按库存金额 TOP10 ─────────────────────────
|
||||
const topSuppliers = useMemo(() => {
|
||||
const map = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const name = String(row.supplier || '').trim() || '未填写供应商';
|
||||
const current = map.get(name) || { codes: 0, qty: 0, amount: 0 };
|
||||
current.codes += 1;
|
||||
current.qty += Number(row.inboundQty || 0);
|
||||
current.amount += Number(row.amount || 0);
|
||||
map.set(name, current);
|
||||
});
|
||||
return Array.from(map, ([name, value]) => ({ name, ...value }))
|
||||
.filter((item) => item.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
.slice(0, 10);
|
||||
}, [rows]);
|
||||
|
||||
const topCodes = useMemo(() => (rows || [])
|
||||
.filter((row) => Number(row.amount || 0) > 0)
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
|
||||
.slice(0, 10), [rows]);
|
||||
|
||||
// ── 图表配置 ───────────────────────────────────────────────
|
||||
const ageChartOption = useMemo(() => ({
|
||||
...sycmEchartsBase,
|
||||
grid: { ...sycmEchartsBase.grid, top: 44, right: 62, bottom: 30, left: 52 },
|
||||
legend: { ...sycmEchartsBase.legend, data: ['编码数', '库存金额'] },
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'category',
|
||||
data: ageStats.tiers.map((tier) => tier.label),
|
||||
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, interval: 0, fontSize: 11 },
|
||||
},
|
||||
yAxis: [
|
||||
{ ...sycmEchartsBase.yAxis, type: 'value', name: '编码数', nameTextStyle: { color: '#8c8c8c', fontSize: 11 } },
|
||||
{
|
||||
...sycmEchartsBase.yAxis,
|
||||
type: 'value',
|
||||
name: '库存金额',
|
||||
nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
|
||||
axisLabel: { ...sycmEchartsBase.yAxis.axisLabel, formatter: compactMoney },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
...sycmEchartsBase.tooltip,
|
||||
formatter: (params) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
if (!list.length) return '';
|
||||
const index = list[0].dataIndex;
|
||||
const tier = ageStats.tiers[index];
|
||||
if (!tier) return '';
|
||||
const rowsHtml = list.map((item) => {
|
||||
const isMoney = item.seriesName === '库存金额';
|
||||
const text = isMoney ? `¥${money(item.value)}` : `${num(item.value)} 个编码`;
|
||||
return `<div style="display:flex;align-items:center;gap:6px;line-height:20px">${item.marker}<span>${item.seriesName}</span><b style="margin-left:auto">${text}</b></div>`;
|
||||
}).join('');
|
||||
return `<div style="font-weight:600;margin-bottom:4px">${tier.label}(${tier.hint})</div>${rowsHtml}
|
||||
<div style="margin-top:4px;opacity:.78">在库 ${num(tier.qty)} 件</div>`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '编码数',
|
||||
type: 'bar',
|
||||
barMaxWidth: 34,
|
||||
data: ageStats.tiers.map((tier) => ({
|
||||
value: tier.codes,
|
||||
itemStyle: { color: tier.color, borderRadius: [4, 4, 0, 0] },
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: '库存金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2.5, color: '#5980ff' },
|
||||
itemStyle: { color: '#5980ff' },
|
||||
data: ageStats.tiers.map((tier) => Math.round(tier.amount * 100) / 100),
|
||||
},
|
||||
],
|
||||
}), [ageStats]);
|
||||
|
||||
const priceChartOption = useMemo(() => {
|
||||
const data = PRICE_ORDER
|
||||
.map((key) => ({
|
||||
name: PRICE_TONES[key].label,
|
||||
value: priceStats[key].codes,
|
||||
itemStyle: { color: PRICE_TONES[key].color },
|
||||
}))
|
||||
.filter((item) => item.value > 0);
|
||||
const total = data.reduce((acc, item) => acc + item.value, 0);
|
||||
return {
|
||||
...pieBase,
|
||||
tooltip: {
|
||||
...pieBase.tooltip,
|
||||
trigger: 'item',
|
||||
formatter: (param) => `<div style="font-weight:600;margin-bottom:4px">${param.marker}${param.name}</div>
|
||||
<div>${num(param.value)} 个编码 · 占比 ${param.percent}%</div>`,
|
||||
},
|
||||
legend: {
|
||||
...pieBase.legend,
|
||||
orient: 'vertical',
|
||||
right: 4,
|
||||
top: 'middle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
icon: 'circle',
|
||||
},
|
||||
title: {
|
||||
text: String(total),
|
||||
subtext: '个编码',
|
||||
left: '33%',
|
||||
top: '38%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 24, fontWeight: 600, color: '#262626' },
|
||||
subtextStyle: { fontSize: 12, color: '#8c8c8c' },
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['56%', '76%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
emphasis: { scale: true, scaleSize: 4 },
|
||||
data,
|
||||
}],
|
||||
};
|
||||
}, [priceStats]);
|
||||
|
||||
const supplierChartOption = useMemo(() => {
|
||||
const source = topSuppliers.slice().reverse();
|
||||
return {
|
||||
...sycmEchartsBase,
|
||||
grid: { top: 10, right: 74, bottom: 6, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
...sycmEchartsBase.tooltip,
|
||||
trigger: 'item',
|
||||
formatter: (param) => {
|
||||
const item = source[param.dataIndex];
|
||||
if (!item) return '';
|
||||
return `<div style="font-weight:600;margin-bottom:4px">${item.name}</div>
|
||||
<div>库存金额 <b>¥${money(item.amount)}</b></div>
|
||||
<div style="opacity:.78">${num(item.codes)} 个编码 · ${num(item.qty)} 件</div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'value',
|
||||
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, formatter: compactMoney },
|
||||
splitLine: { lineStyle: { color: '#eef0f4', type: 'dashed' } },
|
||||
},
|
||||
yAxis: {
|
||||
...sycmEchartsBase.yAxis,
|
||||
type: 'category',
|
||||
data: source.map((item) => item.name),
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#595959', fontSize: 12, width: 130, overflow: 'truncate' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
barMaxWidth: 14,
|
||||
itemStyle: { color: '#5980ff', borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#595959',
|
||||
fontSize: 11,
|
||||
formatter: (param) => `¥${compactMoney(param.value)}`,
|
||||
},
|
||||
data: source.map((item) => Math.round(item.amount * 100) / 100),
|
||||
}],
|
||||
};
|
||||
}, [topSuppliers]);
|
||||
|
||||
const codeChartOption = useMemo(() => {
|
||||
const source = topCodes.slice().reverse();
|
||||
return {
|
||||
...sycmEchartsBase,
|
||||
grid: { top: 10, right: 74, bottom: 6, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
...sycmEchartsBase.tooltip,
|
||||
trigger: 'item',
|
||||
formatter: (param) => {
|
||||
const row = source[param.dataIndex];
|
||||
if (!row) return '';
|
||||
const tone = PRICE_TONES[row.unitPriceLevel] || PRICE_TONES.unknown;
|
||||
return `<div style="font-weight:600;margin-bottom:4px">${row.productCode}</div>
|
||||
<div>库存金额 <b>¥${money(row.amount)}</b></div>
|
||||
<div style="opacity:.78">单价 ¥${money(row.unitPrice)} · ${tone.label} · ${num(row.inboundQty)} 件</div>
|
||||
<div style="opacity:.78">供应商 ${row.supplier || '—'}</div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'value',
|
||||
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, formatter: compactMoney },
|
||||
splitLine: { lineStyle: { color: '#eef0f4', type: 'dashed' } },
|
||||
},
|
||||
yAxis: {
|
||||
...sycmEchartsBase.yAxis,
|
||||
type: 'category',
|
||||
data: source.map((row) => row.productCode),
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#595959', fontSize: 12, width: 140, overflow: 'truncate' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
barMaxWidth: 14,
|
||||
data: source.map((row) => ({
|
||||
value: Math.round(Number(row.amount || 0) * 100) / 100,
|
||||
itemStyle: {
|
||||
color: (PRICE_TONES[row.unitPriceLevel] || PRICE_TONES.unknown).color,
|
||||
borderRadius: [0, 4, 4, 0],
|
||||
},
|
||||
})),
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#595959',
|
||||
fontSize: 11,
|
||||
formatter: (param) => `¥${compactMoney(param.value)}`,
|
||||
},
|
||||
}],
|
||||
};
|
||||
}, [topCodes]);
|
||||
|
||||
const priceBar = PRICE_ORDER.map((key) => ({
|
||||
key,
|
||||
tone: PRICE_TONES[key].tone,
|
||||
label: PRICE_TONES[key].label,
|
||||
hint: key === 'high'
|
||||
? `单价 > ${priceHighMin} 元`
|
||||
: key === 'focus'
|
||||
? `单价 ${priceFocusMin} ~ ${priceHighMin} 元`
|
||||
: key === 'normal'
|
||||
? `单价 < ${priceFocusMin} 元`
|
||||
: '商品资料里没有成本价 / 采购价',
|
||||
codes: priceStats[key].codes,
|
||||
qty: priceStats[key].qty,
|
||||
amount: priceStats[key].amount,
|
||||
}));
|
||||
|
||||
const hasRows = (rows || []).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rsw-price">
|
||||
<div className="rsw-price__thresholds">
|
||||
<span className="rim-toolbar__label">关注 ≥</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={priceFocusMin}
|
||||
onChange={onPriceFocusChange}
|
||||
style={{ width: 100 }}
|
||||
addonAfter="元"
|
||||
/>
|
||||
<span className="rim-toolbar__label" style={{ marginLeft: 6 }}>重点关注 ></span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={priceHighMin}
|
||||
onChange={onPriceHighChange}
|
||||
style={{ width: 100 }}
|
||||
addonAfter="元"
|
||||
/>
|
||||
<Tooltip title={`单价高于重点关注线的编码,要求入库后这么多天内完成库存退厂;在库天数 > ${returnLimit} 天即为「已超期」`}>
|
||||
<span className="rim-toolbar__label rsw-price__limit-label">退厂时限</span>
|
||||
</Tooltip>
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={returnLimit}
|
||||
onChange={onReturnLimitChange}
|
||||
style={{ width: 96 }}
|
||||
addonAfter="天"
|
||||
/>
|
||||
</div>
|
||||
<div className="rsw-warn">
|
||||
{priceBar.map((item) => (
|
||||
<Tooltip key={item.key} title={`${item.hint}|点击${priceLevel === item.key ? '取消' : '只'}看这类编码`}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-tone={item.tone}
|
||||
className={`rsw-warn__item${priceLevel === item.key ? ' is-active' : ''}`}
|
||||
onClick={() => onPriceLevelToggle && onPriceLevelToggle(item.key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (onPriceLevelToggle) onPriceLevelToggle(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="rsw-warn__label">{item.label}</span>
|
||||
<span className="rsw-warn__value">{num(item.codes)}<small>个编码</small></span>
|
||||
<span className="rsw-warn__foot">{num(item.qty)} 件 · ¥{money(item.amount)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasRows ? (
|
||||
<div className="rsw-charts">
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
库龄分布
|
||||
<span className="rsw-chart__hint">分档跟随上方「关注 / 预警」天数自动调整</span>
|
||||
</h3>
|
||||
<ReactECharts option={ageChartOption} style={{ height: chartHeight }} notMerge lazyUpdate />
|
||||
</section>
|
||||
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
单价预警分布
|
||||
<span className="rsw-chart__hint">按编码数占比</span>
|
||||
</h3>
|
||||
<ReactECharts option={priceChartOption} style={{ height: chartHeight }} notMerge lazyUpdate />
|
||||
</section>
|
||||
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
供应商库存金额 TOP10
|
||||
<span className="rsw-chart__hint">压资金最多的供应商</span>
|
||||
</h3>
|
||||
{topSuppliers.length ? (
|
||||
<ReactECharts option={supplierChartOption} style={{ height: chartHeight + 60 }} notMerge lazyUpdate />
|
||||
) : <Empty description="没有可统计的供应商金额" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</section>
|
||||
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
商品编码库存金额 TOP10
|
||||
<span className="rsw-chart__hint">柱子颜色 = 该编码的单价预警等级</span>
|
||||
</h3>
|
||||
{topCodes.length ? (
|
||||
<ReactECharts option={codeChartOption} style={{ height: chartHeight + 60 }} notMerge lazyUpdate />
|
||||
) : <Empty description="没有可统计的编码金额" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
description={loading ? '正在加载数据…' : '当前筛选条件下没有数据'}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockWarningDashboard;
|
||||
725
frontend/src/pages/ReturnsInventoryWarning/monitor.css
Normal file
725
frontend/src/pages/ReturnsInventoryWarning/monitor.css
Normal file
@ -0,0 +1,725 @@
|
||||
/* 横林二仓客退数据监控 —— 页头 / 工具栏 / 指标卡 / 库龄分布 / 数据区 */
|
||||
|
||||
.rim-page {
|
||||
--rim-gap: 12px;
|
||||
--rim-card-border: var(--sycm-border-card, #e6e6e6);
|
||||
--rim-card-radius: var(--sycm-radius-xl, 12px);
|
||||
--rim-card-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
|
||||
--rim-blue: #2062e6;
|
||||
--rim-green: #1bb574;
|
||||
--rim-orange: #ec6c00;
|
||||
--rim-red: #ff4d4f;
|
||||
--rim-cyan: #0ea5e9;
|
||||
--rim-violet: #7c5cf0;
|
||||
}
|
||||
|
||||
/* ── 页头(坐在页面渐变背景上) ───────────────────────────── */
|
||||
.rim-hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
}
|
||||
|
||||
.rim-hero__title {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rim-hero__desc {
|
||||
margin: 4px 0 0;
|
||||
max-width: 900px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rim-hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rim-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rim-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rim-chip b {
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-chip.is-ok {
|
||||
border-color: var(--sycm-color-success-border, #c8f3dd);
|
||||
background: var(--sycm-color-success-bg, #e8fff4);
|
||||
}
|
||||
|
||||
.rim-chip.is-warn {
|
||||
border-color: var(--sycm-color-warning-border, #ffe3b8);
|
||||
background: var(--sycm-color-warning-bg, #fff8e6);
|
||||
}
|
||||
|
||||
.rim-chip.is-danger {
|
||||
border-color: var(--sycm-color-danger-border, #ffd0d0);
|
||||
background: var(--sycm-color-danger-bg, #fff1f0);
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:库龄预警汇总条 ─────────────────────────── */
|
||||
.rsw-warn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rsw-warn__item {
|
||||
--rsw-tone: #595959;
|
||||
--rsw-tone-soft: rgba(0, 0, 0, 0.02);
|
||||
|
||||
flex: 1 1 190px;
|
||||
min-width: 180px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--sycm-border-card, #e6e6e6);
|
||||
border-left: 4px solid var(--rsw-tone);
|
||||
border-radius: 10px;
|
||||
background: var(--rsw-tone-soft);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rsw-warn__item:hover {
|
||||
box-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
|
||||
}
|
||||
|
||||
.rsw-warn__item.is-active {
|
||||
border-color: var(--rsw-tone);
|
||||
box-shadow: 0 0 0 2px var(--rsw-tone-soft);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='red'] {
|
||||
--rsw-tone: #ff4d4f;
|
||||
--rsw-tone-soft: rgba(255, 77, 79, 0.08);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='orange'] {
|
||||
--rsw-tone: #ec6c00;
|
||||
--rsw-tone-soft: rgba(236, 108, 0, 0.08);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='green'] {
|
||||
--rsw-tone: #1bb574;
|
||||
--rsw-tone-soft: rgba(27, 181, 116, 0.08);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='grey'] {
|
||||
--rsw-tone: #94a3b8;
|
||||
--rsw-tone-soft: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.rsw-warn__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
}
|
||||
|
||||
.rsw-warn__value {
|
||||
margin-top: 2px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--rsw-tone);
|
||||
}
|
||||
|
||||
.rsw-warn__value small {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--sycm-text-secondary, #8c8c8c);
|
||||
}
|
||||
|
||||
.rsw-warn__foot {
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rsw-warn__item {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 状态页签(替代原来的三色预警大卡) ───────────────────── */
|
||||
.rim-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 12px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-tabs__item {
|
||||
--rim-tab-tone: #595959;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 12px 8px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rim-tabs__item[data-tone='red'] { --rim-tab-tone: var(--rim-red); }
|
||||
.rim-tabs__item[data-tone='orange'] { --rim-tab-tone: var(--rim-orange); }
|
||||
.rim-tabs__item[data-tone='green'] { --rim-tab-tone: var(--rim-green); }
|
||||
.rim-tabs__item[data-tone='grey'] { --rim-tab-tone: #94a3b8; }
|
||||
.rim-tabs__item[data-tone='blue'] { --rim-tab-tone: var(--rim-blue); }
|
||||
|
||||
.rim-tabs__item:hover {
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
}
|
||||
|
||||
.rim-tabs__item.is-active {
|
||||
border-bottom-color: var(--rim-tab-tone);
|
||||
color: var(--rim-tab-tone);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-tabs__dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--rim-tab-tone);
|
||||
}
|
||||
|
||||
.rim-tabs__count {
|
||||
color: var(--rim-tab-tone);
|
||||
font-size: 15px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-tabs__qty {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 已选筛选回显 ─────────────────────────────────────────── */
|
||||
.rim-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: -4px 0 10px;
|
||||
}
|
||||
|
||||
.rim-filters .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 折叠区(可视化分析 / 口径说明) ──────────────────────── */
|
||||
.rim-fold {
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rim-fold__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.rim-fold__head:hover {
|
||||
background: var(--sycm-bg-subtle, #f5f7fa);
|
||||
}
|
||||
|
||||
.rim-fold__title {
|
||||
flex: none;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-fold__hint {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rim-fold__icon {
|
||||
flex: none;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-fold__body {
|
||||
padding: 2px 16px 14px;
|
||||
}
|
||||
|
||||
.rim-fold__list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── 通用白卡 ─────────────────────────────────────────────── */
|
||||
.rim-panel {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-panel__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-panel__hint {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 表格卡标题右侧的一行元信息(仓库 / 统计日 / 库存更新时间 / 最长在库 …) */
|
||||
.rim-panel__meta {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 筛选工具栏 ───────────────────────────────────────────── */
|
||||
.rim-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-toolbar__field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rim-toolbar__label {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rim-toolbar__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.rim-toolbar__group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 次级筛选行(「更多筛选」展开后出现):浅底,不抢视觉 */
|
||||
.rim-toolbar--sub {
|
||||
margin-top: -6px;
|
||||
background: var(--sycm-bg-subtle, #fafbfc);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rim-toolbar__more {
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
}
|
||||
|
||||
.rim-toolbar__more.is-open {
|
||||
color: var(--rim-blue);
|
||||
}
|
||||
|
||||
/* ── 指标卡 ───────────────────────────────────────────────── */
|
||||
.rim-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--rim-gap);
|
||||
margin-bottom: var(--rim-gap);
|
||||
}
|
||||
|
||||
.rim-metric {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px 11px;
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-metric::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: var(--rim-tone, var(--rim-blue));
|
||||
}
|
||||
|
||||
.rim-metric__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-metric__icon {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: var(--rim-tone-soft, rgba(32, 98, 230, 0.08));
|
||||
color: var(--rim-tone, var(--rim-blue));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-metric__value {
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.rim-metric__unit {
|
||||
margin-left: 2px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rim-metric__foot {
|
||||
margin-top: 4px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-metric[data-tone='blue'] { --rim-tone: var(--rim-blue); --rim-tone-soft: rgba(32, 98, 230, 0.1); }
|
||||
.rim-metric[data-tone='cyan'] { --rim-tone: var(--rim-cyan); --rim-tone-soft: rgba(14, 165, 233, 0.1); }
|
||||
.rim-metric[data-tone='green'] { --rim-tone: var(--rim-green); --rim-tone-soft: rgba(27, 181, 116, 0.1); }
|
||||
.rim-metric[data-tone='orange'] { --rim-tone: var(--rim-orange); --rim-tone-soft: rgba(236, 108, 0, 0.1); }
|
||||
.rim-metric[data-tone='red'] { --rim-tone: var(--rim-red); --rim-tone-soft: rgba(255, 77, 79, 0.1); }
|
||||
.rim-metric[data-tone='violet'] { --rim-tone: var(--rim-violet); --rim-tone-soft: rgba(124, 92, 240, 0.1); }
|
||||
|
||||
/* ── 库龄分布 ─────────────────────────────────────────────── */
|
||||
.rim-age {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--rim-gap);
|
||||
}
|
||||
|
||||
.rim-age__item {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--sycm-border-light, #f0f0f0);
|
||||
border-radius: var(--sycm-radius-lg, 8px);
|
||||
background: var(--sycm-bg-subtle, #f5f7fa);
|
||||
}
|
||||
|
||||
.rim-age__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-age__dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rim-age__value {
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rim-age__value small {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rim-age__track {
|
||||
height: 6px;
|
||||
margin: 8px 0 6px;
|
||||
border-radius: 9999px;
|
||||
background: #e9edf5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rim-age__fill {
|
||||
display: block;
|
||||
min-width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.rim-age__foot {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 退货库存预警:总金额(库存金额)单元格,跟随金额口径强调 */
|
||||
.rsw-amount {
|
||||
color: var(--sycm-color-danger, #cf1322);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:单价预警(阈值 + 可点击汇总条) ─────────── */
|
||||
.rsw-price__thresholds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rsw-price .rsw-warn {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:重点关注商品「退厂要求」提示条 ─────────── */
|
||||
.rsw-policy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #ffe0a3;
|
||||
border-left: 3px solid #faad14;
|
||||
border-radius: 8px;
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
/* 有超期编码时整条转红,提醒优先处理 */
|
||||
.rsw-policy.is-overdue {
|
||||
border-color: #ffccc7;
|
||||
border-left-color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.rsw-policy__icon {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.rsw-policy.is-overdue .rsw-policy__icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.rsw-policy__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rsw-policy__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: #874d00;
|
||||
}
|
||||
|
||||
.rsw-policy.is-overdue .rsw-policy__title {
|
||||
color: #a8071a;
|
||||
}
|
||||
|
||||
.rsw-policy__days {
|
||||
margin: 0 3px;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rsw-policy__desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #8c6b2f;
|
||||
}
|
||||
|
||||
.rsw-policy.is-overdue .rsw-policy__desc {
|
||||
color: #a15c5c;
|
||||
}
|
||||
|
||||
.rsw-policy__desc b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rsw-policy__danger {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.rsw-price__limit-label {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* 表格「退厂时限」列:超期标签加粗,扫一眼就能找到 */
|
||||
.rsw-deadline-tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:图表区 ─────────────────────────────────── */
|
||||
.rsw-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--rim-gap, 12px);
|
||||
}
|
||||
|
||||
.rsw-chart {
|
||||
padding: 10px 12px 4px;
|
||||
border: 1px solid var(--sycm-border-light, #f0f0f0);
|
||||
border-radius: var(--sycm-radius-lg, 8px);
|
||||
background: var(--sycm-bg-subtle, #f5f7fa);
|
||||
}
|
||||
|
||||
.rsw-chart__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 4px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rsw-chart__hint {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.rsw-charts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 数据区 ───────────────────────────────────────────────── */
|
||||
.rim-data {
|
||||
padding: 8px 16px 16px;
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-data .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rim-data .ant-table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rim-data__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── 响应式 ───────────────────────────────────────────────── */
|
||||
@media (max-width: 1440px) {
|
||||
.rim-metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rim-metrics,
|
||||
.rim-age {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
68
frontend/src/pages/ReturnsInventoryWarning/warehouses.js
Normal file
68
frontend/src/pages/ReturnsInventoryWarning/warehouses.js
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 客退数据监控 —— 前端仓库配置
|
||||
* --------------------------------------------------------------------------
|
||||
* 与后端 returnStockWarehouseRegistry.js 一一对应。后端才是取数的唯一来源,
|
||||
* 这里只负责「路由 → 仓库标识 → 页面标题/描述」的映射,不改动任何口径。
|
||||
*
|
||||
* 各仓的「客退上架仓」在聚水潭里的仓位名不同,占用的自定义库存位也不同
|
||||
* (横林/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),文案里要如实写出来,
|
||||
* 否则看数据的人会对不上聚水潭。仓位名以用户 2026-09-17 口径为准。
|
||||
*/
|
||||
|
||||
export const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||||
|
||||
export const WAREHOUSES = [
|
||||
{
|
||||
key: 'henlin2',
|
||||
title: '横林二仓客退数据监控',
|
||||
shortName: '横林二仓',
|
||||
warehouseName: '简尚-横林二仓',
|
||||
returnBinName: '客退临时上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'subei',
|
||||
title: '苏北客退数据监控',
|
||||
shortName: '苏北',
|
||||
warehouseName: '简尚-新苏北仓',
|
||||
returnBinName: '新苏北客退上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'suining',
|
||||
title: '睢宁客退数据监控',
|
||||
shortName: '睢宁',
|
||||
warehouseName: '简尚-睢宁仓',
|
||||
returnBinName: '睢宁客退上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'shandong',
|
||||
title: '山东客退数据监控',
|
||||
shortName: '山东',
|
||||
warehouseName: '简尚-山东青岗集仓',
|
||||
returnBinName: '青岗集客退上架仓',
|
||||
returnSlot: 'customize_qty_2',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'henan',
|
||||
title: '河南客退数据监控',
|
||||
shortName: '河南',
|
||||
warehouseName: '简尚-河南洛宁仓',
|
||||
returnBinName: '河南洛宁客退上架仓',
|
||||
// 三个自定义库存位全为 0,该仓位在副本里取不到数据
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: false,
|
||||
},
|
||||
];
|
||||
|
||||
const WAREHOUSE_MAP = new Map(WAREHOUSES.map((item) => [item.key, item]));
|
||||
|
||||
/** 取仓库配置,非法 key 回落到默认仓(横林二仓) */
|
||||
export const resolveWarehouseConfig = (key) => (
|
||||
WAREHOUSE_MAP.get(String(key || '').trim()) || WAREHOUSE_MAP.get(DEFAULT_WAREHOUSE_KEY)
|
||||
);
|
||||
@ -19,6 +19,7 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent
|
||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
|
||||
@ -111,6 +112,41 @@ const router = createBrowserRouter([
|
||||
path: 'planning/operation/mkh-bom-data',
|
||||
element: renderLazyComponent(MkhBomData),
|
||||
},
|
||||
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2',
|
||||
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henlin2' }),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/subei',
|
||||
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'subei' }),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/suining',
|
||||
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'suining' }),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/shandong',
|
||||
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'shandong' }),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henan',
|
||||
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henan' }),
|
||||
},
|
||||
// 历史路径(子看板 / 页签形态)→ 统一回到主看板
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2/monitor',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/henlin2" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2/inbound',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/henlin2" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2/stock',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/henlin2" replace />,
|
||||
},
|
||||
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||
|
||||
@ -41,6 +41,28 @@ const saveBlob = (blob, fileName) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 服务端直接返回文件的场景(/export 接口,responseType: 'blob')在这里统一落盘
|
||||
export const downloadBlobAsFile = (blob, fileName) => saveBlob(blob, fileName);
|
||||
|
||||
// 导出接口出错时,错误响应体同样是 Blob(内含 JSON),这里统一解出可读文案
|
||||
export const readBlobErrorMessage = async (error, fallback = '导出失败') => {
|
||||
const data = error?.response?.data;
|
||||
if (typeof Blob !== 'undefined' && data instanceof Blob) {
|
||||
try {
|
||||
const text = await data.text();
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed?.error || parsed?.message || fallback;
|
||||
} catch {
|
||||
return text.slice(0, 300) || fallback;
|
||||
}
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return error?.response?.data?.error || error?.message || fallback;
|
||||
};
|
||||
|
||||
const quoteCsvCell = (value) => {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
|
||||
@ -11,6 +11,31 @@ const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
|
||||
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
|
||||
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
|
||||
|
||||
/**
|
||||
* 解析 ssh 可执行文件路径。
|
||||
* 隧道子进程是按「裸命令名 ssh」拉起的,依赖 PATH —— 而计划任务 / 非交互式
|
||||
* 启动时 PATH 往往不含 OpenSSH 目录,隧道就会直接 spawn ENOENT,
|
||||
* 表现为「服务起不来但日志没线索」。这里改成绝对路径优先,找不到再退回裸名。
|
||||
*/
|
||||
function resolveSshBinary() {
|
||||
const candidates = [
|
||||
process.env.CANGKU_SSH,
|
||||
'C:\\Windows\\System32\\OpenSSH\\ssh.exe',
|
||||
'C:\\Program Files\\OpenSSH-Win64\\ssh.exe',
|
||||
'C:\\Program Files (x86)\\OpenSSH-Win64\\ssh.exe',
|
||||
].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
/* 忽略不可访问的候选路径 */
|
||||
}
|
||||
}
|
||||
return 'ssh';
|
||||
}
|
||||
|
||||
const SSH_BINARY = resolveSshBinary();
|
||||
|
||||
/**
|
||||
* 解析后端实际监听端口。
|
||||
* 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。
|
||||
@ -52,6 +77,10 @@ export function computeTunnelRetryDelay(attempt) {
|
||||
}
|
||||
|
||||
export function buildSshArgs(config) {
|
||||
// 隧道稳定性:这条隧道承载「系统配置库」,一旦掉线监督器会连带重启后端
|
||||
// (约 20~90 秒不可用)。日志里高发的 `client_loop: send disconnect: Connection reset`
|
||||
// 属于长时间空闲被中间设备掐断,因此这里缩短保活探测间隔、开启 TCPKeepAlive,
|
||||
// 并给建连加超时,避免连接挂在半死状态。
|
||||
return [
|
||||
'-N', '-T',
|
||||
'-o', 'BatchMode=yes',
|
||||
@ -146,7 +175,14 @@ export async function main(mode = process.argv[2] || 'tunnel') {
|
||||
*/
|
||||
const startTunnel = async () => {
|
||||
const { value: config } = loadConnectionConfig(ROOT_DIR);
|
||||
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
|
||||
const tunnel = spawn(SSH_BINARY, buildSshArgs(config), {
|
||||
cwd: ROOT_DIR,
|
||||
stdio: 'inherit',
|
||||
// Windows 下必须显式隐藏:父进程没有控制台时,spawn 一个控制台程序
|
||||
// 会让系统新分配一个控制台窗口(就是那个反复弹出的 ssh.exe 黑窗)。
|
||||
// 隧道每 60 秒重连一次,不加这个就会一直弹。
|
||||
windowsHide: true,
|
||||
});
|
||||
children.add(tunnel);
|
||||
|
||||
const exitPromise = new Promise((resolve) => {
|
||||
@ -232,6 +268,8 @@ export async function main(mode = process.argv[2] || 'tunnel') {
|
||||
env: childEnvironment,
|
||||
// Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL
|
||||
shell: process.platform === 'win32',
|
||||
// 同理:避免 cmd.exe / node 弹出控制台窗口
|
||||
windowsHide: true,
|
||||
});
|
||||
children.add(child);
|
||||
child.once('exit', (code) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user