Merge pull request #40: 客退数据监控五仓看板 + 本地运行稳定性加固

from codex/cangku-returns-monitor-001 into main
This commit is contained in:
Xkh5201314 2026-09-24 16:06:04 +08:00
commit 7abb75f803
30 changed files with 9498 additions and 2581 deletions

5
.gitignore vendored
View File

@ -62,3 +62,8 @@ tmall-board-speed-check.json
# Local verification artifacts
/outputs/
.git-status.txt
# 本地一次性探查/调试产物(不进仓库)
_probe_*.json
_probe_*.js
_work-scratch-*/

View 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;

View 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;

View File

@ -0,0 +1,132 @@
'use strict';
const express = require('express');
const multer = require('multer');
const {
buildExportColumns,
getReturnStockWarningExportRows,
getReturnStockWarningList,
resolveWarehouse,
resolveWarehouseKey,
} = require('../services/returnStockWarningService');
const {
clearStockWarningImport,
importStockWarning,
} = require('../services/returnStockWarningImport');
const { sendWorkbookDownload } = require('../services/exportWorkbook');
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 },
});
const resolveScope = (value) => (['imported', 'replica'].includes(String(value)) ? String(value) : 'all');
/**
* GET /api/return-stock-warning/list —— 退货库存预警列表(客退数据监控,按仓库出数)
* warehouse: 仓库标识 henlin2 / subei / suining / shandong / henan(缺省 = 横林二仓)
*/
router.get('/list', async (req, res, next) => {
try {
const payload = await getReturnStockWarningList({
warehouse: req.query.warehouse,
page: req.query.page,
pageSize: req.query.pageSize,
keyword: req.query.keyword,
supplier: req.query.supplier,
sortBy: req.query.sortBy,
sortOrder: req.query.sortOrder,
qtyBasis: req.query.qtyBasis,
createdAtBasis: req.query.createdAtBasis,
scope: resolveScope(req.query.scope),
focusDays: req.query.focusDays,
alertDays: req.query.alertDays,
warningLevel: req.query.warningLevel,
priceFocusMin: req.query.priceFocusMin,
priceHighMin: req.query.priceHighMin,
priceLevel: req.query.priceLevel,
returnLimitDays: req.query.returnLimitDays,
refresh: req.query.refresh,
});
res.json({ success: true, data: payload });
} catch (error) {
next(error);
}
});
/** GET /api/return-stock-warning/export —— 服务端全量导出(xlsx / csv) */
router.get('/export', async (req, res, next) => {
try {
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
const keyword = String(req.query.keyword || '').trim();
const warehouse = resolveWarehouse(req.query.warehouse);
const rows = await getReturnStockWarningExportRows({
warehouse: req.query.warehouse,
keyword,
supplier: req.query.supplier,
qtyBasis: req.query.qtyBasis,
createdAtBasis: req.query.createdAtBasis,
scope: resolveScope(req.query.scope),
focusDays: req.query.focusDays,
alertDays: req.query.alertDays,
warningLevel: req.query.warningLevel,
priceFocusMin: req.query.priceFocusMin,
priceHighMin: req.query.priceHighMin,
priceLevel: req.query.priceLevel,
returnLimitDays: req.query.returnLimitDays,
refresh: req.query.refresh,
});
const exportColumns = buildExportColumns(warehouse);
sendWorkbookDownload(res, {
fileName: `${warehouse.shortName}客退数据监控_${keyword || '全部'}`,
format,
sheets: [{
name: `${warehouse.shortName}客退数据监控`,
header: exportColumns.map((column) => column.title),
rows: rows.map((row) => exportColumns.map((column) => row[column.key])),
}],
});
} catch (error) {
next(error);
}
});
/** POST /api/return-stock-warning/import —— 导入聚水潭「箱及仓位库存」导出文件 */
router.post('/import', upload.single('file'), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
res.status(400).json({ success: false, message: '请选择要导入的文件(xlsx / xls / csv)' });
return;
}
// multer 按 latin1 解析 multipart 文件名,中文会变成乱码,这里转回 UTF-8
const rawName = req.file.originalname || '';
const fileName = /[\u00c0-\u00ff]/.test(rawName)
? Buffer.from(rawName, 'latin1').toString('utf8')
: rawName;
const result = await importStockWarning({
buffer: req.file.buffer,
fileName,
importedBy: req.user?.username || req.user?.name || null,
// 导入落在哪个仓库下:优先取表单字段,其次 query,最后回落默认仓
warehouseKey: resolveWarehouseKey(req.body?.warehouse || req.query.warehouse),
});
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
/** DELETE /api/return-stock-warning/import —— 清除当前仓库的导入,回到系统推算口径 */
router.delete('/import', async (req, res, next) => {
try {
const result = await clearStockWarningImport(resolveWarehouseKey(req.query.warehouse));
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@ -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,

File diff suppressed because it is too large Load Diff

View 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 };

View 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,
};

View File

@ -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)后台预热,不阻塞启动

View 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,
};

View 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 };

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,144 @@
'use strict';
/**
* 客退数据监控 —— 仓库注册表
* --------------------------------------------------------------------------
* 每个仓库一张「客退数据监控」看板,页面结构完全一致,差异只有三件事:
* 1. 库存取自哪个仓(jst_inventoryInfo.wms_co_id);
* 2. 「客退临时上架仓」的库存在哪个自定义库存位上;
* 3. 退货单表(售后单 / 奇门退款单)里的销退仓名称(仅「最早入库时间」口径用)。
*
* 关于第 2 点——2026-09-17 用户给出了每个仓在聚水潭里的**准确仓位名**,并明确
* 「看板数据取自该仓位」。聚水潭给每个仓库单独开「自定义库存位」来做客退上架仓,
* **各仓用的位不一样**(2026-09-16 全库核对结论),所以两个字段要分开写:
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
*
* 仓位名 → 库存位 对照(2026-09-17 实测,条件 ISNULL(col,0) > 0):
* 箱及仓位库存 · 简尚-横林二仓 · 客退临时上架仓 customize_qty_1 1274 编码 / 3271 件
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 83 编码 / 317 件
* 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 46 编码 / 173 件
* 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 324 编码 / 625 件
* 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据)
*
* ⚠ 副本里**没有任何仓位维度**(2026-09-17 复核:218 张表 / 5,496 个字段中,列名含
* bin / box / position / location / shelf / slot 的为 0 个;jst_sku.bin、jst_sku.other_bin、
* jst_returnAndRefundItem.box_id、jst_packInventoryInfo.bin 四个疑似字段 100% 为空)。
* 所以「按仓位名取数」在副本上做不到,只能用该仓位绑定的自定义库存位近似;
* 要逐箱还原(含真实的箱上架时间),必须导入聚水潭导出的文件。
*
* 河南洛宁仓(15539084)当天客退库存为 0:仓库存在、也有退货单,但三个自定义库存位
* 全为 0(只有 qty 总库存 83,273 件)。看板照样建,只是空表——页面会提示
* 「该仓当前没有客退在库数据」,等聚水潭侧同步进来后自动有内容。
*/
// 客退临时上架仓在 jst_inventoryInfo 里的候选列
const RETURN_SLOT_COLUMNS = ['customize_qty_1', 'customize_qty_2', 'customize_qty_3'];
const WAREHOUSE_LIST = [
{
key: 'henlin2',
// 页面标题 / 侧边栏名称
title: '横林二仓客退数据监控',
shortName: '横林二仓',
// 仓库主表 jst_warehouseInfo.wms_co_id
wmsCoId: 15033302,
warehouseName: '简尚-横林二仓',
// 退货单(售后单 / 奇门退款单)里的销退仓名称,仅 firstInbound 口径用到
returnWarehouseDb: '简尚-横林二仓销退仓',
// 聚水潭「箱及仓位库存」里对应的仓位名(页面/导出的「数据来源」直接显示它)
returnBinName: '客退临时上架仓',
// 客退临时上架仓占用的自定义库存位
returnSlot: 'customize_qty_1',
// 该仓当天是否有客退在库数据(2026-09-17 实测);空仓时页面给提示
hasReturnStock: true,
},
{
key: 'subei',
title: '苏北客退数据监控',
shortName: '苏北',
wmsCoId: 15038347,
warehouseName: '简尚-新苏北仓',
returnWarehouseDb: '简尚-新苏北仓销退仓',
// 2026-09-17 用户指定:苏北客退数据取自「新苏北客退上架仓」
returnBinName: '新苏北客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'suining',
title: '睢宁客退数据监控',
shortName: '睢宁',
wmsCoId: 15032603,
warehouseName: '简尚-睢宁仓',
returnWarehouseDb: '简尚-睢宁仓销退仓',
// 2026-09-17 用户指定:睢宁客退数据取自「睢宁客退上架仓」
returnBinName: '睢宁客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'shandong',
title: '山东客退数据监控',
shortName: '山东',
wmsCoId: 15137523,
warehouseName: '简尚-山东青岗集仓',
returnWarehouseDb: '简尚-山东青岗集仓销退仓',
// 2026-09-17 用户指定:山东客退数据取自「青岗集客退上架仓」
returnBinName: '青岗集客退上架仓',
// ★ 山东用的是第 2 个自定义库存位,与横林/苏北/睢宁不同
returnSlot: 'customize_qty_2',
hasReturnStock: true,
},
{
key: 'henan',
title: '河南客退数据监控',
shortName: '河南',
wmsCoId: 15539084,
warehouseName: '简尚-河南洛宁仓',
returnWarehouseDb: '简尚-河南洛宁仓销退仓',
// 2026-09-17 用户指定:河南客退数据取自「河南洛宁客退上架仓」
returnBinName: '河南洛宁客退上架仓',
// 该仓三个自定义库存位全为 0,河南洛宁客退上架仓在副本里取不到数据
returnSlot: 'customize_qty_1',
// 当前该仓客退库存为 0(仓库与退货单都在,只是当天没有客退在库)
hasReturnStock: false,
},
];
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
const WAREHOUSE_MAP = new Map(WAREHOUSE_LIST.map((item) => [item.key, item]));
/** 归一化仓库标识:认 key(henlin2 / subei / ...),也容忍直接传 wms_co_id */
function resolveWarehouseKey(value) {
const raw = String(value === null || value === undefined ? '' : value).trim();
if (!raw) return DEFAULT_WAREHOUSE_KEY;
const byKey = WAREHOUSE_MAP.get(raw.toLowerCase());
if (byKey) return byKey.key;
const byId = WAREHOUSE_LIST.find((item) => String(item.wmsCoId) === raw);
return byId ? byId.key : DEFAULT_WAREHOUSE_KEY;
}
/** 取仓库配置(永远返回一个有效配置,非法值回落到默认仓) */
function resolveWarehouse(value) {
return WAREHOUSE_MAP.get(resolveWarehouseKey(value));
}
/**
* 页面/导出文案里的「数据来源」,例如
* 「箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓」
* 仓位名逐仓不同(用户 2026-09-17 口径),必须读注册表,不能写死。
*/
function getWarehouseStockLabel(warehouse) {
return `箱及仓位库存 · ${warehouse.warehouseName} · ${warehouse.returnBinName}`;
}
module.exports = {
WAREHOUSE_LIST,
RETURN_SLOT_COLUMNS,
DEFAULT_WAREHOUSE_KEY,
resolveWarehouse,
resolveWarehouseKey,
getWarehouseStockLabel,
};

View 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,
};

View 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 };

File diff suppressed because it is too large Load Diff

View File

@ -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)) {

View 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,
}
);

View File

@ -0,0 +1,39 @@
import request from './request';
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
// 客退数据监控(箱及仓位库存 · 客退临时上架仓)
// params.warehouse: henlin2 / subei / suining / shandong / henan(缺省 = 横林二仓)
export const getReturnStockWarningList = (params = {}) => request.get(
'/api/return-stock-warning/list',
{ params, timeout: LONG_TIMEOUT_MS }
);
// 导出(xlsx / csv,服务端全量生成,返回二进制)
export const exportReturnStockWarning = (params = {}) => request.get(
'/api/return-stock-warning/export',
{
params,
responseType: 'blob',
timeout: LONG_TIMEOUT_MS,
silentError: true,
}
);
// 导入聚水潭「箱及仓位库存」导出文件(xlsx / xls / csv)
// warehouse: 导入落到哪个仓库下,各仓的导入互不覆盖
export const importReturnStockWarning = (file, warehouse) => {
const formData = new FormData();
formData.append('file', file);
if (warehouse) formData.append('warehouse', warehouse);
return request.post('/api/return-stock-warning/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: LONG_TIMEOUT_MS,
});
};
// 清除当前仓库的导入,回到「系统推算」口径(不影响其它仓)
export const clearReturnStockWarningImport = (warehouse) => request.delete(
'/api/return-stock-warning/import',
{ params: warehouse ? { warehouse } : {} }
);

View File

@ -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-henlin2', label: '横林二仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-henlin2', visible: true, order: 10 },
{ key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 },
{ key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 },
{ key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 },
{ key: 'returns-inventory-henan', label: '河南客退数据监控', icon: 'table', routeKey: 'returns-inventory-henan', visible: true, order: 50 },
],
},
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
{
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
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))
);

View File

@ -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_henlin2', path: '/planning/operation/returns-inventory/henlin2', 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

View 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;

File diff suppressed because it is too large Load Diff

View File

@ -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 }}>重点关注 &gt;</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;

View 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));
}
}

View File

@ -0,0 +1,68 @@
/**
* 客退数据监控 —— 前端仓库配置
* --------------------------------------------------------------------------
* 与后端 returnStockWarehouseRegistry.js 一一对应。后端才是取数的唯一来源,
* 这里只负责「路由 → 仓库标识 → 页面标题/描述」的映射,不改动任何口径。
*
* 各仓的「客退上架仓」在聚水潭里的仓位名不同,占用的自定义库存位也不同
* (横林/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),文案里要如实写出来,
* 否则看数据的人会对不上聚水潭。仓位名以用户 2026-09-17 口径为准。
*/
export const DEFAULT_WAREHOUSE_KEY = 'henlin2';
export const WAREHOUSES = [
{
key: 'henlin2',
title: '横林二仓客退数据监控',
shortName: '横林二仓',
warehouseName: '简尚-横林二仓',
returnBinName: '客退临时上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'subei',
title: '苏北客退数据监控',
shortName: '苏北',
warehouseName: '简尚-新苏北仓',
returnBinName: '新苏北客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'suining',
title: '睢宁客退数据监控',
shortName: '睢宁',
warehouseName: '简尚-睢宁仓',
returnBinName: '睢宁客退上架仓',
returnSlot: 'customize_qty_1',
hasReturnStock: true,
},
{
key: 'shandong',
title: '山东客退数据监控',
shortName: '山东',
warehouseName: '简尚-山东青岗集仓',
returnBinName: '青岗集客退上架仓',
returnSlot: 'customize_qty_2',
hasReturnStock: true,
},
{
key: 'henan',
title: '河南客退数据监控',
shortName: '河南',
warehouseName: '简尚-河南洛宁仓',
returnBinName: '河南洛宁客退上架仓',
// 三个自定义库存位全为 0,该仓位在副本里取不到数据
returnSlot: 'customize_qty_1',
hasReturnStock: false,
},
];
const WAREHOUSE_MAP = new Map(WAREHOUSES.map((item) => [item.key, item]));
/** 取仓库配置,非法 key 回落到默认仓(横林二仓) */
export const resolveWarehouseConfig = (key) => (
WAREHOUSE_MAP.get(String(key || '').trim()) || WAREHOUSE_MAP.get(DEFAULT_WAREHOUSE_KEY)
);

View File

@ -1,159 +1,195 @@
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/henlin2',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henlin2' }),
},
{
path: 'planning/operation/returns-inventory/subei',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'subei' }),
},
{
path: 'planning/operation/returns-inventory/suining',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'suining' }),
},
{
path: 'planning/operation/returns-inventory/shandong',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'shandong' }),
},
{
path: 'planning/operation/returns-inventory/henan',
element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henan' }),
},
// 历史路径(子看板 / 页签形态)→ 统一回到主看板
{
path: 'planning/operation/returns-inventory/henlin2/monitor',
element: <Navigate to="/planning/operation/returns-inventory/henlin2" replace />,
},
{
path: 'planning/operation/returns-inventory/henlin2/inbound',
element: <Navigate to="/planning/operation/returns-inventory/henlin2" replace />,
},
{
path: 'planning/operation/returns-inventory/henlin2/stock',
element: <Navigate to="/planning/operation/returns-inventory/henlin2" replace />,
},
{ path: 'planning/operation/store-checkup', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/review-analysis', element: <Navigate to={HOME_URL} replace /> },
{ path: 'planning/operation/customer-service-checkup', element: <Navigate to={HOME_URL} replace /> },
{ 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;

View File

@ -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;

View File

@ -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) => {