Merge branch 'main' into codex/daily-inventory-snapshot-0924(同步 PR #40/#41)
冲突文件:backend/src/index.js 处理理由:main 侧(他人 PR #40、#41)新增崔桥工厂仓客退三类路由 require 与注册、统一 404 与 JSON 错误处理中间件; 本分支仅新增每日进销存快照任务的 require 与 startDailyInventorySnapshotScheduler() 调用。 采用 main 版本文件并补回本分支两处改动,双方行为均保留。
This commit is contained in:
commit
734b974f45
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-*/
|
||||
|
||||
@ -18,6 +18,8 @@ npm run remote:dev
|
||||
|
||||
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
|
||||
|
||||
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
|
||||
|
||||
## 验证与构建
|
||||
|
||||
```bash
|
||||
|
||||
@ -29,6 +29,10 @@ function sendError(res, error, fallback) {
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
function buildSyncQuery(query = {}) {
|
||||
return { ...query, live: '1', fresh: '1' };
|
||||
}
|
||||
|
||||
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
@ -109,7 +113,9 @@ router.put('/notification-center', async (req, res) => {
|
||||
router.post('/sync', async (req, res) => {
|
||||
try {
|
||||
const cache = clearMkhBomCache();
|
||||
const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' });
|
||||
// fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。
|
||||
// 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。
|
||||
const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query));
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ success: true, data: { cache, snapshot } });
|
||||
} catch (error) {
|
||||
@ -118,3 +124,4 @@ router.post('/sync', async (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports._private = { buildSyncQuery };
|
||||
|
||||
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: 仓库标识 cuiqiao / 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,
|
||||
|
||||
1830
backend/src/index.js
1830
backend/src/index.js
File diff suppressed because it is too large
Load Diff
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)后台预热,不阻塞启动
|
||||
|
||||
@ -376,6 +376,14 @@ let productBomCache = null;
|
||||
*/
|
||||
let cacheEpoch = 0;
|
||||
|
||||
// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰
|
||||
// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工,
|
||||
// 又避免用户每刷新一次就把所有稳定表全量重扫。
|
||||
let operationalCacheEpoch = 0;
|
||||
let operationalRefreshPending = null;
|
||||
let operationalRefreshedAt = 0;
|
||||
const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000;
|
||||
|
||||
// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。
|
||||
// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。
|
||||
const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓';
|
||||
@ -1405,15 +1413,16 @@ async function loadPendingWorkOrderIndex() {
|
||||
});
|
||||
|
||||
const epoch = cacheEpoch;
|
||||
const operationalEpoch = operationalCacheEpoch;
|
||||
workOrderIndexCache = { pending, value: null, expiresAt: 0 };
|
||||
try {
|
||||
const value = await pending;
|
||||
if (epoch === cacheEpoch) {
|
||||
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
|
||||
workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (epoch === cacheEpoch) workOrderIndexCache = null;
|
||||
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -1700,15 +1709,16 @@ async function loadOrderIndex() {
|
||||
});
|
||||
|
||||
const epoch = cacheEpoch;
|
||||
const operationalEpoch = operationalCacheEpoch;
|
||||
orderIndexCache = { pending, value: null, expiresAt: 0 };
|
||||
try {
|
||||
const value = await pending;
|
||||
if (epoch === cacheEpoch) {
|
||||
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
|
||||
orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl };
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (epoch === cacheEpoch) orderIndexCache = null;
|
||||
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -1818,15 +1828,16 @@ async function loadReplenishIndex() {
|
||||
});
|
||||
|
||||
const epoch = cacheEpoch;
|
||||
const operationalEpoch = operationalCacheEpoch;
|
||||
replenishIndexCache = { pending, value: null, expiresAt: 0 };
|
||||
try {
|
||||
const value = await pending;
|
||||
if (epoch === cacheEpoch) {
|
||||
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) {
|
||||
replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS };
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (epoch === cacheEpoch) replenishIndexCache = null;
|
||||
if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -2178,7 +2189,12 @@ async function getMkhBomList(query = {}) {
|
||||
// 默认按可用天数升序 —— 最紧急的排最前
|
||||
const sortField = String(query.sortField || 'stockDays');
|
||||
const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh);
|
||||
const forceFresh = parseFlag(query.fresh);
|
||||
const preferLiveData = parseFlag(query.live) || forceFresh;
|
||||
|
||||
// fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户
|
||||
// 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。
|
||||
if (forceFresh) await refreshMkhOperationalData();
|
||||
|
||||
const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([
|
||||
buildMergedReplenishRows({ preferLiveData }),
|
||||
@ -2255,6 +2271,13 @@ async function getMkhBomList(query = {}) {
|
||||
saleWindowDays: SALE_WINDOW_DAYS,
|
||||
dataMode: preferLiveData ? 'live' : 'snapshot',
|
||||
refreshedAt: new Date().toISOString(),
|
||||
// 前后端实时刷新协议。本地前端如果代理到未重启的旧后端,
|
||||
// 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。
|
||||
freshDataContractVersion: 1,
|
||||
freshDataApplied: forceFresh && operationalRefreshedAt > 0,
|
||||
operationalRefreshedAt: operationalRefreshedAt
|
||||
? new Date(operationalRefreshedAt).toISOString()
|
||||
: null,
|
||||
// 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理
|
||||
workOrderDegraded: replenishIndex.workOrderDegraded === true,
|
||||
workOrderWarehouse: WORK_ORDER_WAREHOUSE,
|
||||
@ -2496,11 +2519,60 @@ async function listMkhBomExportRows(query = {}) {
|
||||
// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照,
|
||||
// 而不是 2~15 分钟前的缓存结果。
|
||||
// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。
|
||||
function clearMkhOperationalCache() {
|
||||
operationalCacheEpoch += 1;
|
||||
operationalRefreshedAt = 0;
|
||||
replenishIndexCache = null;
|
||||
workOrderIndexCache = null;
|
||||
orderIndexCache = null;
|
||||
return {
|
||||
operationalCacheEpoch,
|
||||
clearedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制重读会变的运营数据。
|
||||
* - single-flight:并发用户共用同一次云库查询;
|
||||
* - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库;
|
||||
* - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。
|
||||
*/
|
||||
async function refreshMkhOperationalData() {
|
||||
const now = Date.now();
|
||||
if (operationalRefreshPending) {
|
||||
const result = await operationalRefreshPending;
|
||||
return { ...result, joined: true };
|
||||
}
|
||||
if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) {
|
||||
return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() };
|
||||
}
|
||||
|
||||
clearMkhOperationalCache();
|
||||
const refreshEpoch = operationalCacheEpoch;
|
||||
const pending = (async () => {
|
||||
await loadReplenishIndex();
|
||||
if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now();
|
||||
return {
|
||||
refreshed: true,
|
||||
refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null,
|
||||
};
|
||||
})();
|
||||
operationalRefreshPending = pending;
|
||||
try {
|
||||
return await pending;
|
||||
} finally {
|
||||
if (operationalRefreshPending === pending) operationalRefreshPending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearMkhBomCache() {
|
||||
// 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询,
|
||||
// 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值,
|
||||
// 导致按钮点完反而 2~10 分钟一直显示旧数据)。
|
||||
cacheEpoch += 1;
|
||||
operationalCacheEpoch += 1;
|
||||
operationalRefreshPending = null;
|
||||
operationalRefreshedAt = 0;
|
||||
|
||||
businessBomCache = null;
|
||||
officialBomIndexCache = null;
|
||||
@ -2522,6 +2594,7 @@ function clearMkhBomCache() {
|
||||
return {
|
||||
syncedAt: new Date().toISOString(),
|
||||
cacheEpoch,
|
||||
operationalCacheEpoch,
|
||||
cleared: cleared.map(([name, entries]) => ({ name, entries })),
|
||||
};
|
||||
}
|
||||
@ -2529,6 +2602,7 @@ function clearMkhBomCache() {
|
||||
module.exports = {
|
||||
getMkhBomList,
|
||||
clearMkhBomCache,
|
||||
refreshMkhOperationalData,
|
||||
getMkhBomDetail,
|
||||
listReplenishAlertRows,
|
||||
listMkhBomExportRows,
|
||||
@ -2590,5 +2664,8 @@ module.exports = {
|
||||
EXPORT_MODES,
|
||||
buildProductExportRows,
|
||||
buildComponentExportRows,
|
||||
clearMkhOperationalCache,
|
||||
refreshMkhOperationalData,
|
||||
OPERATIONAL_REFRESH_DEDUPE_MS,
|
||||
},
|
||||
};
|
||||
|
||||
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
155
backend/src/services/returnStockWarehouseRegistry.js
Normal file
155
backend/src/services/returnStockWarehouseRegistry.js
Normal file
@ -0,0 +1,155 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 客退数据监控 —— 仓库注册表
|
||||
* --------------------------------------------------------------------------
|
||||
* 每个仓库一张「客退数据监控」看板,页面结构完全一致,差异只有三件事:
|
||||
* 1. 库存取自哪个仓(jst_inventoryInfo.wms_co_id);
|
||||
* 2. 「客退临时上架仓」的库存在哪个自定义库存位上;
|
||||
* 3. 退货单表(售后单 / 奇门退款单)里的销退仓名称(仅「最早入库时间」口径用)。
|
||||
*
|
||||
* 关于第 2 点——2026-09-17 用户给出了每个仓在聚水潭里的**准确仓位名**,并明确
|
||||
* 「看板数据取自该仓位」。聚水潭给每个仓库单独开「自定义库存位」来做客退上架仓,
|
||||
* **各仓用的位不一样**(2026-09-16 全库核对结论),所以两个字段要分开写:
|
||||
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
|
||||
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
|
||||
*
|
||||
* 仓位名 → 库存位 对照(2026-09-25 复测,条件 ISNULL(col,0) > 0):
|
||||
* 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 2 编码 / 2 件 ⚠
|
||||
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 75 编码 / 362 件
|
||||
* 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 98 编码 / 243 件
|
||||
* 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 403 编码 / 912 件
|
||||
* 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据)
|
||||
*
|
||||
* ⚠ 崔桥工厂仓(15038426)是 2026-09-25 用户指定替换横林二仓的。复测结论:
|
||||
* 副本里该仓客退位只有 2 编码 / 2 件,但退货单量很大(ERP 售后单 3,115 条 +
|
||||
* 奇门退款单 1,812 条,warehouse='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存
|
||||
* 没同步进副本」。全库 218 张表里没有任何「销退仓 / 客退仓 / 上架仓」命名的独立仓
|
||||
* (jst_warehouseInfo 里 name 含「退」的为 0),销退仓只是退货单据里的 warehouse 文本值。
|
||||
* 所以崔桥这张看板在当前副本下**必然近乎空白**,要出数只能:① 让 IT 把该仓客退上架仓
|
||||
* 的库存同步进 jst_inventoryInfo 的自定义库存位;② 用页面「导入」上传聚水潭导出文件。
|
||||
*
|
||||
* ⚠ 副本里**没有任何仓位维度**(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: 'cuiqiao',
|
||||
// 页面标题 / 侧边栏名称(2026-09-25 用户指定:原「横林二仓客退数据监控」改为崔桥工厂仓)
|
||||
title: '崔桥工厂仓客退数据监控',
|
||||
shortName: '崔桥工厂仓',
|
||||
// 仓库主表 jst_warehouseInfo.wms_co_id
|
||||
wmsCoId: 15038426,
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
// 退货单(售后单 / 奇门退款单)里的销退仓名称,仅 firstInbound 口径用到
|
||||
returnWarehouseDb: '简尚-崔桥工厂仓销退仓',
|
||||
// 聚水潭「箱及仓位库存」里对应的仓位名(2026-09-25 用户指定,页面/导出直接显示它)
|
||||
returnBinName: '崔桥工厂客退临时上架仓',
|
||||
// 客退临时上架仓占用的自定义库存位
|
||||
returnSlot: 'customize_qty_1',
|
||||
// ⚠ 2026-09-25 实测:该仓在副本 jst_inventoryInfo 里客退位只有 2 个编码 / 2 件
|
||||
//(qty 总库存 92,818 件;return_qty / c2 / c3 / sale_refund_qty 全为 0)。
|
||||
// 退货单是有的(ERP 售后单 3,115 条 + 奇门退款单 1,812 条),
|
||||
// 说明是「客退上架仓」这一仓位没同步进副本,不是业务上没有退货。
|
||||
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 = 'cuiqiao';
|
||||
|
||||
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
13
backend/test/mkhBomApi.test.js
Normal file
13
backend/test/mkhBomApi.test.js
Normal file
@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { _private } = require('../src/api/mkhBom');
|
||||
|
||||
test('manual MKH sync always forces a live operational refresh', () => {
|
||||
assert.deepEqual(
|
||||
_private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }),
|
||||
{ page: 2, fresh: '1', live: '1' }
|
||||
);
|
||||
});
|
||||
@ -1285,3 +1285,53 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
const originalGetPool = cloudDatabase.getPool;
|
||||
|
||||
let queryCount = 0;
|
||||
let releaseMonitor;
|
||||
let markMonitorStarted;
|
||||
const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; });
|
||||
const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; });
|
||||
|
||||
cloudDatabase.query = async (sql) => {
|
||||
queryCount += 1;
|
||||
if (sql.includes('pm_stockMonitor')) {
|
||||
markMonitorStarted();
|
||||
await monitorGate;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
cloudDatabase.getPool = async () => ({
|
||||
request: () => ({
|
||||
input() { return this; },
|
||||
async query() { queryCount += 1; return { recordset: [] }; },
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const first = svc.refreshMkhOperationalData();
|
||||
await monitorStarted;
|
||||
const second = svc.refreshMkhOperationalData();
|
||||
releaseMonitor();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
assert.equal(firstResult.refreshed, true);
|
||||
assert.equal(secondResult.joined, true);
|
||||
assert.equal(secondResult.refreshedAt, firstResult.refreshedAt);
|
||||
|
||||
const afterConcurrent = queryCount;
|
||||
const reused = await svc.refreshMkhOperationalData();
|
||||
assert.equal(reused.reused, true);
|
||||
assert.equal(queryCount, afterConcurrent,
|
||||
'15 秒保护窗内不应重复访问业务云库');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
cloudDatabase.getPool = originalGetPool;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
@ -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: cuiqiao / 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 } : {} }
|
||||
);
|
||||
@ -1,201 +1,211 @@
|
||||
export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
collapsedByDefault: false,
|
||||
darkModeByDefault: false,
|
||||
entries: [
|
||||
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
|
||||
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
|
||||
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
|
||||
{
|
||||
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
|
||||
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
|
||||
{
|
||||
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||
children: [
|
||||
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
|
||||
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
|
||||
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
|
||||
|
||||
const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
entry.key,
|
||||
...flattenEntryKeys(entry.children || []),
|
||||
]).filter(Boolean);
|
||||
|
||||
const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
entry.routeKey,
|
||||
...flattenRouteKeys(entry.children || []),
|
||||
]).filter(Boolean);
|
||||
|
||||
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
|
||||
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
|
||||
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
|
||||
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
|
||||
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
|
||||
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
|
||||
const extractPromotedTopLevelEntries = (entries = []) => {
|
||||
const promoted = [];
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (!Array.isArray(entry?.children)) return entry;
|
||||
const children = [];
|
||||
entry.children.forEach((child) => {
|
||||
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
|
||||
promoted.push(child);
|
||||
} else {
|
||||
children.push(child);
|
||||
}
|
||||
});
|
||||
return children.length === entry.children.length ? entry : { ...entry, children };
|
||||
});
|
||||
return { nextEntries, promoted };
|
||||
};
|
||||
|
||||
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
|
||||
const configuredKeys = new Set();
|
||||
|
||||
const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
|
||||
const merged = { ...defaultEntry, ...configuredEntry };
|
||||
if (defaultEntry.type) {
|
||||
merged.type = defaultEntry.type;
|
||||
}
|
||||
if (defaultEntry.routeKey) {
|
||||
merged.routeKey = defaultEntry.routeKey;
|
||||
}
|
||||
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
|
||||
merged.requiresAdmin = defaultEntry.requiresAdmin;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
|
||||
) {
|
||||
merged.label = defaultEntry.label;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
|
||||
) {
|
||||
merged.order = defaultEntry.order;
|
||||
}
|
||||
const hasDefaultChildren = Array.isArray(defaultEntry.children);
|
||||
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
|
||||
|
||||
if (hasDefaultChildren || hasConfiguredChildren) {
|
||||
if (hasDefaultChildren) {
|
||||
merged.type = defaultEntry.type;
|
||||
} else if (defaultEntry.routeKey) {
|
||||
merged.type = defaultEntry.type || 'item';
|
||||
delete merged.children;
|
||||
delete merged.defaultExpanded;
|
||||
return merged;
|
||||
}
|
||||
merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const configuredMerged = configuredEntries.map((entry) => {
|
||||
if (entry?.key) {
|
||||
configuredKeys.add(entry.key);
|
||||
}
|
||||
|
||||
const defaultEntry = defaultByKey.get(entry?.key);
|
||||
return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
|
||||
});
|
||||
|
||||
const missingDefaults = defaultEntries
|
||||
.filter((entry) => entry?.key && !configuredKeys.has(entry.key))
|
||||
.map((entry) => mergeEntry(entry, {}));
|
||||
|
||||
return [...configuredMerged, ...missingDefaults];
|
||||
};
|
||||
|
||||
const mergeSidebarConfigWithDefaults = (config) => {
|
||||
const defaults = cloneDefaultConfig();
|
||||
const nestedEntries = (config?.entries || []);
|
||||
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
|
||||
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
|
||||
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...config,
|
||||
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
|
||||
};
|
||||
};
|
||||
|
||||
const pruneSidebarEntry = (entry) => {
|
||||
if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = Array.isArray(entry.children)
|
||||
? entry.children.map(pruneSidebarEntry).filter(Boolean)
|
||||
: undefined;
|
||||
const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
|
||||
const hasAllowedChildren = Array.isArray(children) && children.length > 0;
|
||||
const keepEntry = hasAllowedRoute || hasAllowedChildren;
|
||||
|
||||
if (!keepEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
...(Array.isArray(entry.children) ? { children } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const removeRetiredEntries = (config) => ({
|
||||
...config,
|
||||
entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
|
||||
});
|
||||
|
||||
export const parseSidebarConfig = (value) => {
|
||||
if (!value) return cloneDefaultConfig();
|
||||
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
|
||||
return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
|
||||
} catch {
|
||||
return cloneDefaultConfig();
|
||||
}
|
||||
};
|
||||
|
||||
export const sortSidebarEntries = (entries = []) => (
|
||||
[...entries]
|
||||
.filter((entry) => entry?.visible !== false)
|
||||
.sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
|
||||
);
|
||||
export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
collapsedByDefault: false,
|
||||
darkModeByDefault: false,
|
||||
entries: [
|
||||
{ key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 },
|
||||
{ key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 },
|
||||
{ key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 },
|
||||
{
|
||||
key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 },
|
||||
{ key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'returns-inventory', type: 'group', label: '退货库存监控', icon: 'inbox', visible: true, order: 75, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'returns-inventory-cuiqiao', label: '崔桥工厂仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-cuiqiao', 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,
|
||||
children: [
|
||||
{ key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 },
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||
children: [
|
||||
{ key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true },
|
||||
{ key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true },
|
||||
{ key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG));
|
||||
|
||||
const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
entry.key,
|
||||
...flattenEntryKeys(entry.children || []),
|
||||
]).filter(Boolean);
|
||||
|
||||
const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
entry.routeKey,
|
||||
...flattenRouteKeys(entry.children || []),
|
||||
]).filter(Boolean);
|
||||
|
||||
const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries));
|
||||
|
||||
// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置
|
||||
const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板';
|
||||
const OPS_B_INCENTIVE_LEGACY_ORDER = 6;
|
||||
const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']);
|
||||
// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并
|
||||
const extractPromotedTopLevelEntries = (entries = []) => {
|
||||
const promoted = [];
|
||||
const nextEntries = entries.map((entry) => {
|
||||
if (!Array.isArray(entry?.children)) return entry;
|
||||
const children = [];
|
||||
entry.children.forEach((child) => {
|
||||
if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) {
|
||||
promoted.push(child);
|
||||
} else {
|
||||
children.push(child);
|
||||
}
|
||||
});
|
||||
return children.length === entry.children.length ? entry : { ...entry, children };
|
||||
});
|
||||
return { nextEntries, promoted };
|
||||
};
|
||||
|
||||
const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => {
|
||||
const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key));
|
||||
const configuredKeys = new Set();
|
||||
|
||||
const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => {
|
||||
const merged = { ...defaultEntry, ...configuredEntry };
|
||||
if (defaultEntry.type) {
|
||||
merged.type = defaultEntry.type;
|
||||
}
|
||||
if (defaultEntry.routeKey) {
|
||||
merged.routeKey = defaultEntry.routeKey;
|
||||
}
|
||||
if (typeof defaultEntry.requiresAdmin !== 'undefined') {
|
||||
merged.requiresAdmin = defaultEntry.requiresAdmin;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL)
|
||||
) {
|
||||
merged.label = defaultEntry.label;
|
||||
}
|
||||
if (
|
||||
defaultEntry.key === 'ops-b-incentive'
|
||||
&& (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER)
|
||||
) {
|
||||
merged.order = defaultEntry.order;
|
||||
}
|
||||
const hasDefaultChildren = Array.isArray(defaultEntry.children);
|
||||
const hasConfiguredChildren = Array.isArray(configuredEntry.children);
|
||||
|
||||
if (hasDefaultChildren || hasConfiguredChildren) {
|
||||
if (hasDefaultChildren) {
|
||||
merged.type = defaultEntry.type;
|
||||
} else if (defaultEntry.routeKey) {
|
||||
merged.type = defaultEntry.type || 'item';
|
||||
delete merged.children;
|
||||
delete merged.defaultExpanded;
|
||||
return merged;
|
||||
}
|
||||
merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const configuredMerged = configuredEntries.map((entry) => {
|
||||
if (entry?.key) {
|
||||
configuredKeys.add(entry.key);
|
||||
}
|
||||
|
||||
const defaultEntry = defaultByKey.get(entry?.key);
|
||||
return defaultEntry ? mergeEntry(defaultEntry, entry) : entry;
|
||||
});
|
||||
|
||||
const missingDefaults = defaultEntries
|
||||
.filter((entry) => entry?.key && !configuredKeys.has(entry.key))
|
||||
.map((entry) => mergeEntry(entry, {}));
|
||||
|
||||
return [...configuredMerged, ...missingDefaults];
|
||||
};
|
||||
|
||||
const mergeSidebarConfigWithDefaults = (config) => {
|
||||
const defaults = cloneDefaultConfig();
|
||||
const nestedEntries = (config?.entries || []);
|
||||
const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries);
|
||||
const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean));
|
||||
const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted;
|
||||
|
||||
return {
|
||||
...defaults,
|
||||
...config,
|
||||
entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]),
|
||||
};
|
||||
};
|
||||
|
||||
const pruneSidebarEntry = (entry) => {
|
||||
if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = Array.isArray(entry.children)
|
||||
? entry.children.map(pruneSidebarEntry).filter(Boolean)
|
||||
: undefined;
|
||||
const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false;
|
||||
const hasAllowedChildren = Array.isArray(children) && children.length > 0;
|
||||
const keepEntry = hasAllowedRoute || hasAllowedChildren;
|
||||
|
||||
if (!keepEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
...(Array.isArray(entry.children) ? { children } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const removeRetiredEntries = (config) => ({
|
||||
...config,
|
||||
entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean),
|
||||
});
|
||||
|
||||
export const parseSidebarConfig = (value) => {
|
||||
if (!value) return cloneDefaultConfig();
|
||||
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig();
|
||||
return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed));
|
||||
} catch {
|
||||
return cloneDefaultConfig();
|
||||
}
|
||||
};
|
||||
|
||||
export const sortSidebarEntries = (entries = []) => (
|
||||
[...entries]
|
||||
.filter((entry) => entry?.visible !== false)
|
||||
.sort((a, b) => Number(a.order || 0) - Number(b.order || 0))
|
||||
);
|
||||
|
||||
@ -5,6 +5,11 @@ export const SIDEBAR_PAGES = [
|
||||
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
|
||||
{ pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
];
|
||||
|
||||
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch,
|
||||
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch,
|
||||
Table, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
@ -18,6 +18,7 @@ import {
|
||||
syncMkhBom,
|
||||
} from '../../api/mkhBom';
|
||||
import { createRequestGate } from '../../utils/requestGate';
|
||||
import { requireMkhFreshContract } from '../../utils/mkhFreshContract';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
@ -33,6 +34,22 @@ const number = (value, digits = 0) => (value === null || value === undefined ||
|
||||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||
|
||||
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
|
||||
// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询
|
||||
// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。
|
||||
const AUTO_REFRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
const getRefreshProgress = (elapsedSeconds) => {
|
||||
if (elapsedSeconds < 3) {
|
||||
return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' };
|
||||
}
|
||||
if (elapsedSeconds < 15) {
|
||||
return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' };
|
||||
}
|
||||
return {
|
||||
percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)),
|
||||
stage: '正在合并最新数据与 BOM 配件结果…',
|
||||
};
|
||||
};
|
||||
|
||||
// 预警等级 → 标签配色
|
||||
const WARNING_TONE = {
|
||||
@ -263,6 +280,7 @@ const MkhBomData = () => {
|
||||
// 导出(当前筛选条件下的全部数据)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' });
|
||||
|
||||
// 补货预警推送设置
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
@ -277,8 +295,35 @@ const MkhBomData = () => {
|
||||
const tableWrapRef = useRef(null);
|
||||
const listRequestGateRef = useRef(createRequestGate());
|
||||
const detailGenerationRef = useRef(0);
|
||||
const refreshProgressGenerationRef = useRef(0);
|
||||
const lastListRefreshAtRef = useRef(0);
|
||||
const autoRefreshPendingRef = useRef(false);
|
||||
const [bodyHeight, setBodyHeight] = useState(420);
|
||||
|
||||
const beginRefreshProgress = useCallback((title) => {
|
||||
refreshProgressGenerationRef.current += 1;
|
||||
const generation = refreshProgressGenerationRef.current;
|
||||
setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation });
|
||||
return generation;
|
||||
}, []);
|
||||
|
||||
const endRefreshProgress = useCallback((generation) => {
|
||||
if (generation !== refreshProgressGenerationRef.current) return;
|
||||
setRefreshProgress((current) => ({ ...current, open: false }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshProgress.open) return undefined;
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setRefreshProgress((current) => ({
|
||||
...current,
|
||||
elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
|
||||
}));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshProgress.open]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tableWrapRef.current;
|
||||
if (!el) return undefined;
|
||||
@ -313,28 +358,72 @@ const MkhBomData = () => {
|
||||
return params;
|
||||
}, [page, pageSize, sorter, filters]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async ({ background = false } = {}) => {
|
||||
const requestGeneration = listRequestGateRef.current.begin();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
let showProgress = false;
|
||||
let progressGeneration = null;
|
||||
if (!background) setLoading(true);
|
||||
if (!background) setError('');
|
||||
try {
|
||||
const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() });
|
||||
const now = Date.now();
|
||||
const forceFresh = !lastListRefreshAtRef.current
|
||||
|| now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS;
|
||||
showProgress = forceFresh && !background;
|
||||
if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据');
|
||||
// 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。
|
||||
// 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。
|
||||
const result = await getMkhBomList({
|
||||
...query,
|
||||
live: 1,
|
||||
...(forceFresh ? { fresh: 1 } : null),
|
||||
_refresh: now,
|
||||
});
|
||||
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
|
||||
if (!result || result.success === false) {
|
||||
setError(result?.error || '加载 MKH-BOM 资料失败');
|
||||
return;
|
||||
}
|
||||
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
|
||||
const snapshot = result?.data || null;
|
||||
try {
|
||||
requireMkhFreshContract(snapshot, { requireApplied: forceFresh });
|
||||
} catch (contractError) {
|
||||
setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
|
||||
throw contractError;
|
||||
}
|
||||
setData(snapshot);
|
||||
if (forceFresh) lastListRefreshAtRef.current = Date.now();
|
||||
setError('');
|
||||
} catch (requestError) {
|
||||
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
|
||||
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
|
||||
} finally {
|
||||
if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
|
||||
if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
|
||||
if (showProgress) endRefreshProgress(progressGeneration);
|
||||
}
|
||||
}, [query]);
|
||||
}, [beginRefreshProgress, endRefreshProgress, query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。
|
||||
// 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。
|
||||
useEffect(() => {
|
||||
const refreshIfStale = () => {
|
||||
if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return;
|
||||
if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return;
|
||||
autoRefreshPendingRef.current = true;
|
||||
load({ background: true }).finally(() => {
|
||||
autoRefreshPendingRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS);
|
||||
document.addEventListener('visibilitychange', refreshIfStale);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', refreshIfStale);
|
||||
};
|
||||
}, [load, syncing]);
|
||||
|
||||
const changeFilter = (key) => (value) => {
|
||||
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||||
setPage(1);
|
||||
@ -813,6 +902,7 @@ const MkhBomData = () => {
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setLoading(true);
|
||||
const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据');
|
||||
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
|
||||
listRequestGateRef.current.invalidate();
|
||||
detailGenerationRef.current += 1;
|
||||
@ -826,7 +916,8 @@ const MkhBomData = () => {
|
||||
if (!result || result.success === false || !result?.data?.snapshot) {
|
||||
throw new Error(result?.error || '同步接口未返回数据库快照');
|
||||
}
|
||||
setData(result.data.snapshot);
|
||||
setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true }));
|
||||
lastListRefreshAtRef.current = Date.now();
|
||||
setError('');
|
||||
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
|
||||
} catch (syncError) {
|
||||
@ -837,11 +928,31 @@ const MkhBomData = () => {
|
||||
} finally {
|
||||
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
|
||||
setSyncing(false);
|
||||
endRefreshProgress(progressGeneration);
|
||||
}
|
||||
};
|
||||
|
||||
const progressView = getRefreshProgress(refreshProgress.elapsedSeconds);
|
||||
|
||||
return (
|
||||
<SycmPage className="mkh-page">
|
||||
<Modal
|
||||
open={refreshProgress.open}
|
||||
title={refreshProgress.title}
|
||||
footer={null}
|
||||
closable={false}
|
||||
keyboard={false}
|
||||
maskClosable={false}
|
||||
centered
|
||||
width={480}
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Progress percent={progressView.percent} status="active" strokeColor="#1677ff" />
|
||||
<Text>{progressView.stage}</Text>
|
||||
<Text type="secondary">已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。</Text>
|
||||
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
<header className="mkh-header">
|
||||
<div className="mkh-header__heading">
|
||||
<Title level={3}>MKH-BOM 资料</Title>
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
69
frontend/src/pages/ReturnsInventoryWarning/warehouses.js
Normal file
69
frontend/src/pages/ReturnsInventoryWarning/warehouses.js
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 客退数据监控 —— 前端仓库配置
|
||||
* --------------------------------------------------------------------------
|
||||
* 与后端 returnStockWarehouseRegistry.js 一一对应。后端才是取数的唯一来源,
|
||||
* 这里只负责「路由 → 仓库标识 → 页面标题/描述」的映射,不改动任何口径。
|
||||
*
|
||||
* 各仓的「客退上架仓」在聚水潭里的仓位名不同,占用的自定义库存位也不同
|
||||
* (崔桥/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),文案里要如实写出来,
|
||||
* 否则看数据的人会对不上聚水潭。仓位名以用户口径为准(2026-09-25 起首仓改为崔桥工厂仓)。
|
||||
*/
|
||||
|
||||
export const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
|
||||
|
||||
export const WAREHOUSES = [
|
||||
{
|
||||
key: 'cuiqiao',
|
||||
title: '崔桥工厂仓客退数据监控',
|
||||
shortName: '崔桥工厂仓',
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
returnBinName: '崔桥工厂客退临时上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
// 2026-09-25 实测:副本里该仓客退位只有 2 编码 / 2 件(退货单有量,库存没同步)
|
||||
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)
|
||||
);
|
||||
@ -1,159 +1,203 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import BasicLayout from '../layouts/BasicLayout';
|
||||
import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton';
|
||||
|
||||
const Login = lazy(() => import('../pages/Login'));
|
||||
const Install = lazy(() => import('../pages/Install'));
|
||||
const Setup = lazy(() => import('../pages/Setup'));
|
||||
const Settings = lazy(() => import('../pages/Settings'));
|
||||
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
|
||||
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
|
||||
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
|
||||
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
|
||||
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
|
||||
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
|
||||
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
|
||||
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
|
||||
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
|
||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
|
||||
const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => {
|
||||
const LazyView = lazyView;
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<LazyView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const HOME_URL = '/planning/dev-style-preview';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
element: renderLazyComponent(Setup),
|
||||
},
|
||||
{
|
||||
path: '/install',
|
||||
element: renderLazyComponent(Install),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <BasicLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'channel',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-events',
|
||||
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-events/activity-products',
|
||||
element: renderLazyComponent(MarketingEvents),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-tools',
|
||||
element: <Navigate to="/planning/operation/marketing-events" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-information',
|
||||
element: renderLazyComponent(ProductInformation),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-analysis',
|
||||
element: renderLazyComponent(ProductAnalysis),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/promotion-module',
|
||||
element: renderLazyComponent(PromotionModule),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/ops-b-incentive',
|
||||
element: renderLazyComponent(OpsBIncentiveBoard),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-monitoring',
|
||||
element: renderLazyComponent(ProductMonitoring),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/modular-stock-monitor',
|
||||
element: renderLazyComponent(ModularStockMonitor),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/mkh-bom-data',
|
||||
element: renderLazyComponent(MkhBomData),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/daily-inventory',
|
||||
element: renderLazyComponent(DailyInventory),
|
||||
},
|
||||
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
|
||||
{
|
||||
path: 'planning/operation/:featureKey',
|
||||
element: renderLazyComponent(OperationPlaceholder),
|
||||
},
|
||||
{
|
||||
path: 'planning/upload',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/dev-style-preview',
|
||||
element: renderLazyComponent(DevelopmentStylePreview),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: renderLazyComponent(Settings),
|
||||
},
|
||||
{
|
||||
path: 'settings/online-desktop-users',
|
||||
element: renderLazyComponent(OnlineDesktopUsers),
|
||||
},
|
||||
{
|
||||
path: 'settings/desktop-execution-tasks',
|
||||
element: renderLazyComponent(DesktopExecutionTasks),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import BasicLayout from '../layouts/BasicLayout';
|
||||
import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton';
|
||||
|
||||
const Login = lazy(() => import('../pages/Login'));
|
||||
const Install = lazy(() => import('../pages/Install'));
|
||||
const Setup = lazy(() => import('../pages/Setup'));
|
||||
const Settings = lazy(() => import('../pages/Settings'));
|
||||
const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers'));
|
||||
const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks'));
|
||||
const MarketingEvents = lazy(() => import('../pages/MarketingEvents'));
|
||||
const ProductInformation = lazy(() => import('../pages/ProductInformation'));
|
||||
const PromotionModule = lazy(() => import('../pages/PromotionModule'));
|
||||
const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder'));
|
||||
const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview'));
|
||||
const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis'));
|
||||
const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard'));
|
||||
const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring'));
|
||||
const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor'));
|
||||
const MkhBomData = lazy(() => import('../pages/MkhBomData'));
|
||||
const DailyInventory = lazy(() => import('../pages/DailyInventory'));
|
||||
const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor'));
|
||||
|
||||
const lazyLoadingElement = <PageLoadingFallback />;
|
||||
|
||||
const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => {
|
||||
const LazyView = lazyView;
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
<LazyView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const HOME_URL = '/planning/dev-style-preview';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
|
||||
},
|
||||
{
|
||||
path: '/callback',
|
||||
element: renderLazyComponent(Login, {}, <AuthPageLoadingFallback />),
|
||||
},
|
||||
{
|
||||
path: '/setup',
|
||||
element: renderLazyComponent(Setup),
|
||||
},
|
||||
{
|
||||
path: '/install',
|
||||
element: renderLazyComponent(Install),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <BasicLayout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'channel',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-events',
|
||||
element: <Navigate to="/planning/operation/marketing-events/activity-products" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-events/activity-products',
|
||||
element: renderLazyComponent(MarketingEvents),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/marketing-tools',
|
||||
element: <Navigate to="/planning/operation/marketing-events" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-information',
|
||||
element: renderLazyComponent(ProductInformation),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-analysis',
|
||||
element: renderLazyComponent(ProductAnalysis),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/promotion-module',
|
||||
element: renderLazyComponent(PromotionModule),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/ops-b-incentive',
|
||||
element: renderLazyComponent(OpsBIncentiveBoard),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/product-monitoring',
|
||||
element: renderLazyComponent(ProductMonitoring),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/modular-stock-monitor',
|
||||
element: renderLazyComponent(ModularStockMonitor),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/mkh-bom-data',
|
||||
element: renderLazyComponent(MkhBomData),
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/daily-inventory',
|
||||
element: renderLazyComponent(DailyInventory),
|
||||
},
|
||||
// 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓;
|
||||
// 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线)
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/cuiqiao',
|
||||
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'cuiqiao' }),
|
||||
},
|
||||
{
|
||||
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',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2/monitor',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2/inbound',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/henlin2/stock',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/operation/returns-inventory/cuiqiao/monitor',
|
||||
element: <Navigate to="/planning/operation/returns-inventory/cuiqiao" 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 /> },
|
||||
{ path: 'planning/operation/pricing-calculator', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/desktop-client-download', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/ai-buyer-show', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'planning/operation/ai-seeding-video', element: <Navigate to={HOME_URL} replace /> },
|
||||
{ path: 'settings/desktop-client-releases', element: <Navigate to={HOME_URL} replace /> },
|
||||
{
|
||||
path: 'planning/operation/:featureKey',
|
||||
element: renderLazyComponent(OperationPlaceholder),
|
||||
},
|
||||
{
|
||||
path: 'planning/upload',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
{
|
||||
path: 'planning/dev-style-preview',
|
||||
element: renderLazyComponent(DevelopmentStylePreview),
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
element: renderLazyComponent(Settings),
|
||||
},
|
||||
{
|
||||
path: 'settings/online-desktop-users',
|
||||
element: renderLazyComponent(OnlineDesktopUsers),
|
||||
},
|
||||
{
|
||||
path: 'settings/desktop-execution-tasks',
|
||||
element: renderLazyComponent(DesktopExecutionTasks),
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to={HOME_URL} replace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export default router;
|
||||
|
||||
15
frontend/src/utils/mkhFreshContract.js
Normal file
15
frontend/src/utils/mkhFreshContract.js
Normal file
@ -0,0 +1,15 @@
|
||||
export const MKH_FRESH_DATA_CONTRACT_VERSION = 1;
|
||||
|
||||
const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。'
|
||||
+ '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。';
|
||||
|
||||
export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) {
|
||||
const contractVersion = Number(snapshot?.freshDataContractVersion) || 0;
|
||||
if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) {
|
||||
throw new Error(OUTDATED_BACKEND_MESSAGE);
|
||||
}
|
||||
if (requireApplied && snapshot?.freshDataApplied !== true) {
|
||||
throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
23
frontend/test/mkhFreshContract.test.mjs
Normal file
23
frontend/test/mkhFreshContract.test.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js';
|
||||
|
||||
test('MKH fresh contract accepts a backend-confirmed live refresh', () => {
|
||||
const snapshot = { freshDataContractVersion: 1, freshDataApplied: true };
|
||||
assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot);
|
||||
});
|
||||
|
||||
test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => {
|
||||
assert.throws(
|
||||
() => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }),
|
||||
/本地后端版本过旧/
|
||||
);
|
||||
});
|
||||
|
||||
test('MKH fresh contract requires explicit refresh completion for fresh requests', () => {
|
||||
assert.throws(
|
||||
() => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }),
|
||||
/未确认已重读/
|
||||
);
|
||||
});
|
||||
@ -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