From b3f4d1af6407d3e05c4a8d386bc4151728d695d9 Mon Sep 17 00:00:00 2001 From: lunanting Date: Wed, 23 Sep 2026 11:32:16 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=AE=A2=E9=80=80=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=91=E6=8E=A7=E4=BA=94=E4=BB=93=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=E6=9C=AC=E5=9C=B0=E8=BF=90=E8=A1=8C=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E6=80=A7=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能: - 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板, 按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2) - 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商 (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入) - 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单 - 服务端导出(xlsx/csv),不受列表分页与展示上限约束 稳定性: - 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500 - 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML - 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗 - 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时 合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现, 本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。 --- .gitignore | 5 + backend/src/api/returnInventoryWarning.js | 58 + backend/src/api/returnMonitor.js | 161 ++ backend/src/api/returnStockWarning.js | 132 ++ backend/src/config/cloudDatabase.js | 4 + backend/src/index.js | 38 + backend/src/jobs/returnMonitorJob.js | 34 + backend/src/services/exportWorkbook.js | 100 ++ backend/src/services/localSchema.js | 9 + .../services/returnInventoryWarningService.js | 412 ++++++ backend/src/services/returnMonitorSchema.js | 128 ++ backend/src/services/returnMonitorService.js | 1313 +++++++++++++++++ .../services/returnStockWarehouseRegistry.js | 144 ++ .../src/services/returnStockWarningImport.js | 275 ++++ .../src/services/returnStockWarningSchema.js | 130 ++ .../src/services/returnStockWarningService.js | 1245 ++++++++++++++++ frontend/src/api/request.js | 77 +- frontend/src/api/returnMonitor.js | 48 + frontend/src/api/returnStockWarning.js | 39 + frontend/src/config/sidebarNavigation.js | 10 + frontend/src/layouts/BasicLayout.jsx | 211 ++- .../pages/ReturnsInventoryWarning/Monitor.jsx | 51 + .../ReturnsInventoryWarning/StockWarning.jsx | 1067 ++++++++++++++ .../StockWarningDashboard.jsx | 485 ++++++ .../pages/ReturnsInventoryWarning/monitor.css | 725 +++++++++ .../ReturnsInventoryWarning/warehouses.js | 68 + frontend/src/router/index.jsx | 36 + frontend/src/utils/tableExport.js | 22 + scripts/start-remote.mjs | 40 +- 29 files changed, 6990 insertions(+), 77 deletions(-) create mode 100644 backend/src/api/returnInventoryWarning.js create mode 100644 backend/src/api/returnMonitor.js create mode 100644 backend/src/api/returnStockWarning.js create mode 100644 backend/src/jobs/returnMonitorJob.js create mode 100644 backend/src/services/exportWorkbook.js create mode 100644 backend/src/services/returnInventoryWarningService.js create mode 100644 backend/src/services/returnMonitorSchema.js create mode 100644 backend/src/services/returnMonitorService.js create mode 100644 backend/src/services/returnStockWarehouseRegistry.js create mode 100644 backend/src/services/returnStockWarningImport.js create mode 100644 backend/src/services/returnStockWarningSchema.js create mode 100644 backend/src/services/returnStockWarningService.js create mode 100644 frontend/src/api/returnMonitor.js create mode 100644 frontend/src/api/returnStockWarning.js create mode 100644 frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx create mode 100644 frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx create mode 100644 frontend/src/pages/ReturnsInventoryWarning/StockWarningDashboard.jsx create mode 100644 frontend/src/pages/ReturnsInventoryWarning/monitor.css create mode 100644 frontend/src/pages/ReturnsInventoryWarning/warehouses.js diff --git a/.gitignore b/.gitignore index 84188f3..8c83a37 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,8 @@ tmall-board-speed-check.json # Local verification artifacts /outputs/ .git-status.txt + +# 本地一次性探查/调试产物(不进仓库) +_probe_*.json +_probe_*.js +_work-scratch-*/ diff --git a/backend/src/api/returnInventoryWarning.js b/backend/src/api/returnInventoryWarning.js new file mode 100644 index 0000000..e32bd7d --- /dev/null +++ b/backend/src/api/returnInventoryWarning.js @@ -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; diff --git a/backend/src/api/returnMonitor.js b/backend/src/api/returnMonitor.js new file mode 100644 index 0000000..9fe50c6 --- /dev/null +++ b/backend/src/api/returnMonitor.js @@ -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; diff --git a/backend/src/api/returnStockWarning.js b/backend/src/api/returnStockWarning.js new file mode 100644 index 0000000..613a83a --- /dev/null +++ b/backend/src/api/returnStockWarning.js @@ -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; diff --git a/backend/src/config/cloudDatabase.js b/backend/src/config/cloudDatabase.js index 37c7d2f..d46496f 100644 --- a/backend/src/config/cloudDatabase.js +++ b/backend/src/config/cloudDatabase.js @@ -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, diff --git a/backend/src/index.js b/backend/src/index.js index 88daa64..ebe583b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -35,6 +35,9 @@ const promotionModuleRoutes = require('./api/promotionModule'); const productMonitoringRoutes = require('./api/productMonitoring'); const modularStockMonitorRoutes = require('./api/modularStockMonitor'); const mkhBomRoutes = require('./api/mkhBom'); +const returnInventoryWarningRoutes = require('./api/returnInventoryWarning'); +const returnMonitorRoutes = require('./api/returnMonitor'); +const returnStockWarningRoutes = require('./api/returnStockWarning'); const database = require('./config/database'); const cloudDb = require('./config/cloudDatabase'); const jianSunDb = require('./config/jianSunDatabase'); @@ -659,6 +662,11 @@ async function startServer(options = {}) { cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes); cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); + cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes); + // 横林二仓客退:退货入库核销 + 每日库存监控 + cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); + // 横林二仓客退:退货库存预警(箱及仓位库存 · 客退临时上架仓) + cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes); // ===================== // 数据分析 API(公开访问,可根据需要添加认证) @@ -694,6 +702,36 @@ async function startServer(options = {}) { } }); + // ===================== + // 统一 API 兜底(必须放在所有路由之后) + // ===================== + // 背景:此前没有任何 4 参错误处理中间件,路由里抛出的异常会落到 Express + // 默认处理器,返回一张 HTML 的 "Internal Server Error" 页面;前端 axios + // 读不到 error / message 字段,只能显示 "Request failed with status code 500", + // 出问题时完全看不到真实原因。这里统一返回 JSON 并打印堆栈。 + cubejsApp.use('/api', (req, res) => { + res.status(404).json({ + success: false, + error: `接口不存在: ${req.method} ${req.originalUrl}`, + }); + }); + + cubejsApp.use((err, req, res, next) => { + if (res.headersSent) { + return next(err); + } + const rawStatus = Number(err?.status || err?.statusCode); + const status = Number.isFinite(rawStatus) && rawStatus >= 400 && rawStatus <= 599 + ? rawStatus + : 500; + const messageText = err?.message || '服务器内部错误'; + console.error(`❌ [API ${status}] ${req.method} ${req.originalUrl} -> ${messageText}`); + if (err?.stack) { + console.error(err.stack); + } + return res.status(status).json({ success: false, error: messageText }); + }); + console.log('='.repeat(50)); console.log('🚀 后端服务已启动'); console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); diff --git a/backend/src/jobs/returnMonitorJob.js b/backend/src/jobs/returnMonitorJob.js new file mode 100644 index 0000000..8c40465 --- /dev/null +++ b/backend/src/jobs/returnMonitorJob.js @@ -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 }; diff --git a/backend/src/services/exportWorkbook.js b/backend/src/services/exportWorkbook.js new file mode 100644 index 0000000..b249c49 --- /dev/null +++ b/backend/src/services/exportWorkbook.js @@ -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, +}; diff --git a/backend/src/services/localSchema.js b/backend/src/services/localSchema.js index 3e3d5fa..4074beb 100644 --- a/backend/src/services/localSchema.js +++ b/backend/src/services/localSchema.js @@ -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)后台预热,不阻塞启动 diff --git a/backend/src/services/returnInventoryWarningService.js b/backend/src/services/returnInventoryWarningService.js new file mode 100644 index 0000000..a246fac --- /dev/null +++ b/backend/src/services/returnInventoryWarningService.js @@ -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, +}; diff --git a/backend/src/services/returnMonitorSchema.js b/backend/src/services/returnMonitorSchema.js new file mode 100644 index 0000000..a5da6e0 --- /dev/null +++ b/backend/src/services/returnMonitorSchema.js @@ -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 }; diff --git a/backend/src/services/returnMonitorService.js b/backend/src/services/returnMonitorService.js new file mode 100644 index 0000000..ba00c6b --- /dev/null +++ b/backend/src/services/returnMonitorService.js @@ -0,0 +1,1313 @@ +'use strict'; + +/** + * 横林二仓客退「退货入库核销 + 每日库存监控」服务 + * ========================================================================== + * 业务口径(与需求一一对应) + * -------------------------------------------------------------------------- + * 核心:退货入库(一单一行,主数据) + * 字段:退货单号 · 商品编码 · 数量 · 供应商 · 入库日期(取单据日期)· 状态(在库/已出库/已报废)· 出库日期 + * 库龄 = 今天 − 入库日期,查询时实时计算(DATEDIFF(CURDATE(), inbound_date)),所以每天自动重算。 + * + * 每日比对(自动核销,按商品编码)—— 见 runReconciliation() + * ① 退货入库件数 > 0 且 现有库存该编码 = 0 / 无此编码 → 整批已出库,自动核销为「已出库」,出库日期 = 当天 + * ② 退货入库件数 > 现有库存 > 0 → 部分出库,进差异清单人工确认(FIFO / 手动指定) + * 现有库存 ≥ 退货入库件数 → 仍在库,不动 + * + * 数据来源(业务库 JSReportReplica / schema rpt) + * 当日退货入库:聚水潭「销售退仓 横林二仓」= 业务库 warehouse N'简尚-横林二仓销退仓' + * = ① ERP 售后单 jst_returnAndRefundInfo × jst_returnAndRefundItem + * ∪ ② 奇门退款单 jst_qmcust_refundlist × jst_qmcust_refundlist_item + * (按 receive_date 明细收货日过滤;两条管线互不重叠,取并集才与聚水潭对得上) + * 现有库存: 需求指定「箱及仓位库存-横林二仓-客退临时上架仓」 + * → 副本无箱/仓位维度(box_id / sku.bin 全空,也无仓位级库存表), + * 以 jst_inventoryInfo.return_qty(横林二仓 wms_co_id=15033302 的退货库存)作为仓级近似。 + * + * 为什么需要本地表:状态/出库日期/差异确认结果是"比对后累积"的状态,业务库没有,必须落本地库持久化。 + * ========================================================================== + */ + +const cloudDb = require('../config/cloudDatabase'); +const database = require('../config/database'); + +const WAREHOUSE_KEY = 'henlin2'; +const WAREHOUSE_DISPLAY = '常州客退仓'; +// 业务库真实仓库名 +const WAREHOUSE_DB = '简尚-横林二仓销退仓'; +// 横林二仓在 jst_inventoryInfo 的仓编码 +const HENLIN2_WMS_CO_ID = 15033302; +// 「客退临时上架仓」在聚水潭属于横林二仓的退货库存(return_qty) +const STOCK_SOURCE_LABEL = '箱及仓位库存 横林二仓 客退临时上架仓(近似:jst_inventoryInfo.return_qty)'; + +const DEFAULT_OVERDUE_DAYS = 15; +const MAX_PAGE_SIZE = 2000; +const SYNC_CHUNK = 500; +const BUSINESS_TIMEOUT = 240000; + +const STATUS_IN_STOCK = '在库'; +const STATUS_OUTBOUND = '已出库'; +const STATUS_SCRAP = '已报废'; + +const GAPS = [ + `现有库存口径:需求指定「箱及仓位库存-横林二仓-客退临时上架仓」(箱号+仓位级)。但报表副本 rpt 中没有该维度——jst_returnAndRefundItem.box_id 全表 236,184 行为空、jst_sku.bin/other_bin 全表 558,123 行为空,也没有仓位级库存表。当前改用 jst_inventoryInfo.return_qty(横林二仓 wms_co_id=${HENLIN2_WMS_CO_ID} 的退货库存,仓库级)作为近似值。`, + '出库日期:副本中没有「聚水潭采购退货-横林二仓」单据表(jst_purchase 是采购入库单)。因此出库日期是由「每日自动核销」规则推定写入的,不是聚水潭单据上的原始出库时间。', + '条数口径(2026-09-15 已修正):入库明细取「ERP 售后单 + 奇门退款单」两条同步管线的并集,去重键为(退货单号 + 商品编码 + 入库日期)。实测 09-14 并集 1179 条 vs 聚水潭「销售退仓」1194 条(吻合度 98.7%)。剩余 1~2% 差额来自副本未覆盖的 WMS 退仓单据(退仓单号/上架状态/箱号维度在 rpt 副本中不存在)。', + '仅统计入库件数 > 0 的退货行;r_qty 为 0 时回退取 qty。', +]; + +// --------------------------------------------------------------------------- +// 基础工具 +// --------------------------------------------------------------------------- + +function todayInShanghai() { + const now = new Date(); + const utcMs = now.getTime() + now.getTimezoneOffset() * 60000; + return new Date(utcMs + 8 * 3600000).toISOString().slice(0, 10); +} + +function normalizeDateInput(value) { + const v = String(value || '').trim(); + if (!v) return ''; + const m = v.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/); + if (!m) return ''; + return `${m[1]}-${String(m[2]).padStart(2, '0')}-${String(m[3]).padStart(2, '0')}`; +} + +function shiftDate(dateStr, days) { + const d = new Date(`${dateStr}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +const toInt = (value, fallback = 0) => { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? n : fallback; +}; + +/** + * MySQL DATE 列经 mysql2 会返回本地时区的 Date 对象, + * 直接 toISOString() 会因时区偏移少一天,必须按本地日期分量格式化。 + */ +function formatLocalDate(value) { + if (!value) return null; + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null; + return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`; + } + return String(value).slice(0, 10); +} + +/** DATETIME 列同理按本地分量格式化,避免 UTC 偏移导致时间错 8 小时 */ +function formatLocalDateTime(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())}`; +} + +// --------------------------------------------------------------------------- +// 业务库同步 +// --------------------------------------------------------------------------- + +// 当日退货入库:一单一行(退货单 × 商品编码),只取入库件数 > 0 的行。 +// +// 口径(2026-09-15 修正):副本里有两条互不重叠的退货入库同步管线,必须取并集才对得上 +// 聚水潭「销售退仓」当日条数: +// ① ERP 售后单 jst_returnAndRefundInfo × jst_returnAndRefundItem(状态以 WaitConfirm 为主) +// ② 奇门退款单 jst_qmcust_refundlist × jst_qmcust_refundlist_item(状态以 Confirmed 为主) +// 实测 2026-09-14:① 797 条、② 497 条,仅 115 条重叠,并集 1179 条 vs 聚水潭 1194 条(98.7%)。 +// 去重键 = (returnNo, productCode, inboundDate),与本地表 UNIQUE KEY 一致;同键优先取 ERP 行。 +function buildInboundSyncSql() { + return ` + WITH src AS ( + SELECT + LTRIM(RTRIM(COALESCE( + NULLIF(TRY_CONVERT(NVARCHAR(120), i.as_id), N''), + NULLIF(TRY_CONVERT(NVARCHAR(120), i.outer_as_id), N''), + NULLIF(TRY_CONVERT(NVARCHAR(120), i.l_id), N'') + ))) AS [returnNo], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), it.sku_id))) AS [productCode], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sk.name))) AS [skuName], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sk.supplier_name))) AS [supplier], + ISNULL(NULLIF(it.r_qty, 0), it.qty) AS [qty], + CONVERT(NVARCHAR(10), CAST(it.receive_date AS DATE), 23) AS [inboundDate], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), i.l_id))) AS [expressNo], + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), i.so_id))) AS [orderNo], + TRY_CONVERT(INT, it.defective_qty) AS [defectiveQty], + 1 AS [srcRank] + FROM dbo.jst_returnAndRefundInfo i + JOIN dbo.jst_returnAndRefundItem it ON it.as_id = i.as_id + LEFT JOIN dbo.jst_sku sk ON sk.sku_id = it.sku_id + WHERE i.warehouse = N'${WAREHOUSE_DB}' + AND it.sku_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), it.sku_id))) <> N'' + AND ISNULL(NULLIF(it.r_qty, 0), it.qty) > 0 + AND it.receive_date IS NOT NULL + AND CAST(it.receive_date AS DATE) BETWEEN @fromDate AND @toDate + UNION ALL + SELECT + LTRIM(RTRIM(COALESCE( + NULLIF(TRY_CONVERT(NVARCHAR(120), q.as_id), N''), + NULLIF(TRY_CONVERT(NVARCHAR(120), q.outer_as_id), N''), + NULLIF(TRY_CONVERT(NVARCHAR(120), q.l_id), N'') + ))), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), qit.sku_id))), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sk2.name))), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), sk2.supplier_name))), + ISNULL(NULLIF(qit.r_qty, 0), qit.qty), + CONVERT(NVARCHAR(10), CAST(qit.receive_date AS DATE), 23), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), q.l_id))), + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), q.so_id))), + TRY_CONVERT(INT, qit.defective_qty), + 2 + 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) + LEFT JOIN dbo.jst_sku sk2 ON sk2.sku_id = qit.sku_id + WHERE q.warehouse = N'${WAREHOUSE_DB}' + AND qit.sku_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), qit.sku_id))) <> N'' + AND ISNULL(NULLIF(qit.r_qty, 0), qit.qty) > 0 + AND qit.receive_date IS NOT NULL + AND CAST(qit.receive_date AS DATE) BETWEEN @fromDate AND @toDate + ), + ranked AS ( + SELECT src.*, + ROW_NUMBER() OVER ( + PARTITION BY src.returnNo, UPPER(src.productCode), src.inboundDate + ORDER BY src.srcRank + ) AS rn + FROM src + ) + SELECT returnNo, productCode, skuName, supplier, qty, inboundDate, expressNo, orderNo, defectiveQty + FROM ranked + WHERE rn = 1 + `; +} + +// 现有库存(「客退临时上架仓」近似口径):横林二仓的退货库存 +function buildStockSyncSql() { + return ` + SELECT + LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), inv.sku_id))) AS [productCode], + ISNULL(inv.return_qty, 0) AS [stockQty] + FROM dbo.jst_inventoryInfo inv + WHERE inv.wms_co_id = ${HENLIN2_WMS_CO_ID} + AND inv.sku_id IS NOT NULL + AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(120), inv.sku_id))) <> N'' + `; +} + +async function upsertInboundRows(rows = []) { + if (!rows.length) return 0; + let written = 0; + for (let i = 0; i < rows.length; i += SYNC_CHUNK) { + const chunk = rows.slice(i, i + SYNC_CHUNK); + await database.local.query( + `INSERT INTO return_monitor_inbound + (warehouse_key, return_no, product_code, sku_name, supplier, qty, inbound_date, express_no, order_no, defective_qty) + VALUES ? + ON DUPLICATE KEY UPDATE + qty = VALUES(qty), + sku_name = VALUES(sku_name), + supplier = VALUES(supplier), + express_no = VALUES(express_no), + order_no = VALUES(order_no), + defective_qty = VALUES(defective_qty), + last_synced_at = CURRENT_TIMESTAMP`, + [chunk] + ); + written += chunk.length; + } + return written; +} + +async function upsertStockRows(statDate, rows = []) { + if (!rows.length) return 0; + const records = rows.map((row) => [WAREHOUSE_KEY, statDate, row.productCode, toInt(row.stockQty, 0)]); + for (let i = 0; i < records.length; i += SYNC_CHUNK) { + const chunk = records.slice(i, i + SYNC_CHUNK); + await database.local.query( + `INSERT INTO return_monitor_stock (warehouse_key, stat_date, product_code, stock_qty) + VALUES ? + ON DUPLICATE KEY UPDATE stock_qty = VALUES(stock_qty), synced_at = CURRENT_TIMESTAMP`, + [chunk] + ); + } + return records.length; +} + +/** + * 同步指定区间内的「退货入库」明细到本地主数据表。 + */ +async function syncReturnInboundRange({ fromDate, toDate } = {}) { + const rows = await cloudDb.query( + buildInboundSyncSql(), + { fromDate, toDate }, + { requestTimeout: BUSINESS_TIMEOUT } + ); + + const normalized = (rows || []) + .map((row) => ({ + returnNo: row.returnNo ? String(row.returnNo) : null, + productCode: row.productCode ? String(row.productCode) : null, + skuName: row.skuName ? String(row.skuName) : null, + supplier: row.supplier ? String(row.supplier) : null, + qty: toInt(row.qty, 0), + inboundDate: normalizeDateInput(row.inboundDate), + expressNo: row.expressNo ? String(row.expressNo) : null, + orderNo: row.orderNo ? String(row.orderNo) : null, + defectiveQty: row.defectiveQty === null || row.defectiveQty === undefined ? null : toInt(row.defectiveQty, 0), + })) + .filter((row) => row.returnNo && row.productCode && row.inboundDate && row.qty > 0); + + const written = await upsertInboundRows(normalized.map((row) => [ + WAREHOUSE_KEY, + row.returnNo.slice(0, 120), + row.productCode.slice(0, 120), + row.skuName ? row.skuName.slice(0, 255) : null, + row.supplier ? row.supplier.slice(0, 255) : null, + row.qty, + row.inboundDate, + row.expressNo ? row.expressNo.slice(0, 120) : null, + row.orderNo ? row.orderNo.slice(0, 120) : null, + row.defectiveQty, + ])); + + return { fromDate, toDate, fetched: normalized.length, written }; +} + +/** + * 同步「当日退货入库」明细:days 为回溯天数(默认 3 天), + * 因为副本里销退仓的退货明细存在同步滞后(当天可能还没到齐)。 + */ +async function syncReturnInbound({ date, days = 3 } = {}) { + const toDate = normalizeDateInput(date) || todayInShanghai(); + const fromDate = shiftDate(toDate, -Math.max(0, toInt(days, 3))); + return syncReturnInboundRange({ fromDate, toDate }); +} + +/** + * 历史回填:库龄分布 / 超期清单需要更长历史,首次接入时按月分片补齐。 + * 分片是为了避免单次业务库查询超时。 + */ +async function backfillHistory({ days = 180, chunkDays = 30, toDate, onProgress } = {}) { + const endDate = normalizeDateInput(toDate) || todayInShanghai(); + const startDate = shiftDate(endDate, -Math.max(1, toInt(days, 180))); + let cursor = startDate; + let written = 0; + let windows = 0; + + while (cursor <= endDate) { + const windowEnd = shiftDate(cursor, Math.max(1, toInt(chunkDays, 30)) - 1) > endDate + ? endDate + : shiftDate(cursor, Math.max(1, toInt(chunkDays, 30)) - 1); + const result = await syncReturnInboundRange({ fromDate: cursor, toDate: windowEnd }); + written += result.written; + windows += 1; + if (typeof onProgress === 'function') { + onProgress({ window: windows, from: cursor, to: windowEnd, written: result.written }); + } + cursor = shiftDate(windowEnd, 1); + } + + return { from: startDate, to: endDate, windows, written }; +} + +async function countInboundRows() { + const rows = await database.local.query( + 'SELECT COUNT(*) AS n FROM return_monitor_inbound WHERE warehouse_key = ?', + [WAREHOUSE_KEY] + ); + return toInt(rows?.[0]?.n, 0); +} + +/** 首次接入(本地表为空)时自动补齐历史 */ +async function ensureHistoryBootstrapped({ days = 180, toDate } = {}) { + if (await countInboundRows() > 0) return null; + console.log(`[ReturnMonitor] 本地入库表为空,开始回填最近 ${days} 天历史`); + const result = await backfillHistory({ days, toDate }); + console.log(`[ReturnMonitor] 历史回填完成: ${JSON.stringify(result)}`); + return result; +} + +/** 同步现有库存快照 */ +async function syncCurrentStock({ date } = {}) { + const statDate = normalizeDateInput(date) || todayInShanghai(); + const rows = await cloudDb.query(buildStockSyncSql(), {}, { requestTimeout: BUSINESS_TIMEOUT }); + const normalized = (rows || []) + .map((row) => ({ + productCode: row.productCode ? String(row.productCode).trim() : '', + stockQty: toInt(row.stockQty, 0), + })) + .filter((row) => row.productCode); + + const written = await upsertStockRows(statDate, normalized); + return { statDate, fetched: normalized.length, written }; +} + +// --------------------------------------------------------------------------- +// 每日比对 / 自动核销 +// --------------------------------------------------------------------------- + +async function loadLatestStockMap() { + const rows = await database.local.query( + `SELECT product_code, stock_qty + FROM return_monitor_stock + WHERE warehouse_key = ? + AND stat_date = (SELECT MAX(stat_date) FROM return_monitor_stock WHERE warehouse_key = ?)`, + [WAREHOUSE_KEY, WAREHOUSE_KEY] + ); + const map = new Map(); + rows.forEach((row) => map.set(String(row.product_code), toInt(row.stock_qty, 0))); + return map; +} + +async function loadLatestStockDate() { + const rows = await database.local.query( + 'SELECT MAX(stat_date) AS d FROM return_monitor_stock WHERE warehouse_key = ?', + [WAREHOUSE_KEY] + ); + return formatLocalDate(rows?.[0]?.d); +} + +/** + * 「库存更新时间」= 本系统最近一次拉取库存快照的时间(本地快照表的写入时间), + * 用于判断当前库存数据是否新鲜,而不是业务库快照的业务日期。 + */ +async function loadLatestStockSyncedAt() { + try { + const rows = await cloudDb.query( + `SELECT MAX(updateTime) AS t + FROM dbo.jst_inventoryInfo + WHERE wms_co_id = ${HENLIN2_WMS_CO_ID}`, + {}, + { requestTimeout: 120000 } + ); + const synced = formatLocalDateTime(rows?.[0]?.t); + if (synced) return synced; + } catch (error) { + // 业务库不可用时降级为本地快照写入时间 + } + + const rows = await database.local.query( + `SELECT MAX(synced_at) AS t + FROM return_monitor_stock + WHERE warehouse_key = ? + AND stat_date = (SELECT MAX(stat_date) FROM return_monitor_stock WHERE warehouse_key = ?)`, + [WAREHOUSE_KEY, WAREHOUSE_KEY] + ); + return formatLocalDateTime(rows?.[0]?.t); +} + +async function loadInStockAggregates() { + return database.local.query( + `SELECT product_code, + SUM(qty) AS inboundTotal, + MIN(inbound_date) AS firstInboundDate, + MAX(supplier) AS supplier + FROM return_monitor_inbound + WHERE warehouse_key = ? AND status = ? AND qty > 0 + GROUP BY product_code + HAVING SUM(qty) > 0`, + [WAREHOUSE_KEY, STATUS_IN_STOCK] + ); +} + +async function loadInStockLines(productCode) { + return database.local.query( + `SELECT id, qty, inbound_date + FROM return_monitor_inbound + WHERE warehouse_key = ? AND product_code = ? AND status = ? AND qty > 0 + ORDER BY inbound_date ASC, id ASC`, + [WAREHOUSE_KEY, productCode, STATUS_IN_STOCK] + ); +} + +const toDateOnly = formatLocalDate; + +// FIFO 建议:按入库日期从早到晚累计,直到覆盖「推定已出库件数」。 +// 只保留被建议核销的行(数量可能很多,故入库时截断,完整清单走 code-lines 接口按需查)。 +const FIFO_PLAN_PREVIEW_LIMIT = 50; + +function buildFifoPlan(lines, diffQty, previewLimit = FIFO_PLAN_PREVIEW_LIMIT) { + const plan = []; + let remaining = diffQty; + for (const line of lines) { + if (remaining <= 0) break; + const qty = toInt(line.qty, 0); + const take = Math.min(remaining, qty); + if (plan.length < previewLimit) { + plan.push({ + id: line.id, + qty, + inboundDate: toDateOnly(line.inbound_date), + suggestOutQty: take, + suggested: true, + fullLine: take >= qty, + }); + } + remaining -= take; + } + return plan; +} + +// 实际执行 FIFO 时按数据库里的最新在库行实时重算,避免依赖截断后的展示用计划 +function buildFifoLineIds(lines, diffQty) { + const ids = []; + let remaining = diffQty; + for (const line of lines) { + if (remaining <= 0) break; + ids.push(toInt(line.id, 0)); + remaining -= toInt(line.qty, 0); + } + return ids.filter((id) => id > 0); +} + +/** + * 每日比对:按商品编码把「退货入库」与「现有库存」对上,写回状态/出库日期,并产出差异清单。 + */ +async function runReconciliation({ date } = {}) { + const bizDate = normalizeDateInput(date) || todayInShanghai(); + const stockMap = await loadLatestStockMap(); + const aggregates = await loadInStockAggregates(); + + const toClear = []; + const discrepancies = []; + + for (const row of aggregates) { + const productCode = String(row.product_code); + const inboundTotal = toInt(row.inboundTotal, 0); + if (inboundTotal <= 0) continue; + + const stockQty = stockMap.has(productCode) ? stockMap.get(productCode) : 0; + + // 规则①:入库有量、现有库存为 0 或编码不存在 → 整批已出库 + if (stockQty <= 0) { + toClear.push(productCode); + continue; + } + + // 规则②:部分出库 → 差异清单 + if (inboundTotal > stockQty) { + const lines = await loadInStockLines(productCode); + const diffQty = inboundTotal - stockQty; + const totalQty = lines.reduce((sum, line) => sum + toInt(line.qty, 0), 0); + discrepancies.push({ + productCode, + supplier: row.supplier ? String(row.supplier).slice(0, 255) : null, + inboundTotal, + stockQty, + diffQty, + fifoPlan: buildFifoPlan(lines, diffQty), + inStockLines: lines.length, + inStockQty: totalQty, + }); + } + // 其余:现有库存 ≥ 入库件数 → 仍在库 + } + + // 整批核销 + let clearedRows = 0; + if (toClear.length) { + for (let i = 0; i < toClear.length; i += SYNC_CHUNK) { + const chunk = toClear.slice(i, i + SYNC_CHUNK); + const placeholders = chunk.map(() => '?').join(', '); + const result = await database.local.query( + `UPDATE return_monitor_inbound + SET status = ?, outbound_date = ?, writeoff_mode = 'auto_clear', + remark = CONCAT('自动核销:现有库存为 0,整批出库(', ?, ')') + WHERE warehouse_key = ? AND status = ? AND product_code IN (${placeholders})`, + [STATUS_OUTBOUND, bizDate, bizDate, WAREHOUSE_KEY, STATUS_IN_STOCK, ...chunk] + ); + clearedRows += Number(result?.affectedRows || 0); + } + } + + // 差异清单 upsert + const stockDate = await loadLatestStockDate(); + for (const item of discrepancies) { + await database.local.query( + `INSERT INTO return_monitor_discrepancy + (warehouse_key, product_code, supplier, inbound_total, stock_qty, diff_qty, + fifo_plan_json, status, first_seen_date, last_seen_date) + VALUES (?, ?, ?, ?, ?, ?, ?, '待确认', ?, ?) + ON DUPLICATE KEY UPDATE + supplier = VALUES(supplier), + inbound_total = VALUES(inbound_total), + stock_qty = VALUES(stock_qty), + diff_qty = VALUES(diff_qty), + fifo_plan_json = VALUES(fifo_plan_json), + last_seen_date = VALUES(last_seen_date)`, + [ + WAREHOUSE_KEY, + item.productCode, + item.supplier, + item.inboundTotal, + item.stockQty, + item.diffQty, + JSON.stringify({ + plan: item.fifoPlan, + planTruncated: item.fifoPlan.length < item.inStockLines, + inStockLines: item.inStockLines, + inStockQty: item.inStockQty, + stockDate, + }), + bizDate, + bizDate, + ] + ); + } + + // 不再有差异的编码 → 从清单移除(已确认核销后自然消失) + const currentCodes = discrepancies.map((item) => item.productCode); + if (currentCodes.length) { + const placeholders = currentCodes.map(() => '?').join(', '); + await database.local.query( + `DELETE FROM return_monitor_discrepancy + WHERE warehouse_key = ? AND product_code NOT IN (${placeholders})`, + [WAREHOUSE_KEY, ...currentCodes] + ); + } else { + await database.local.query('DELETE FROM return_monitor_discrepancy WHERE warehouse_key = ?', [WAREHOUSE_KEY]); + } + + return { + bizDate, + inStockCodes: aggregates.length, + clearedCodes: toClear.length, + clearedRows, + discrepancyCodes: discrepancies.length, + }; +} + +/** 完整一轮:同步入库 + 同步库存 + 比对,并记日志 */ +async function runDailyCompare({ date, trigger = 'api', lookbackDays = 3, bootstrapDays = 180 } = {}) { + const bizDate = normalizeDateInput(date) || todayInShanghai(); + const startedAt = Date.now(); + try { + const bootstrap = toInt(bootstrapDays, 180) > 0 + ? await ensureHistoryBootstrapped({ days: bootstrapDays, toDate: bizDate }) + : null; + const inbound = await syncReturnInbound({ date: bizDate, days: lookbackDays }); + const stock = await syncCurrentStock({ date: bizDate }); + const recon = await runReconciliation({ date: bizDate }); + const elapsedMs = Date.now() - startedAt; + + await database.local.query( + `INSERT INTO return_monitor_job_log + (warehouse_key, biz_date, trigger_type, inbound_rows, stock_rows, cleared_rows, discrepancy_rows, status, elapsed_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, 'success', ?)`, + [WAREHOUSE_KEY, bizDate, trigger, inbound.written, stock.written, recon.clearedRows, recon.discrepancyCodes, elapsedMs] + ); + + return { + ...recon, + inboundRows: inbound.written, + stockRows: stock.written, + bootstrap: bootstrap ? { from: bootstrap.from, to: bootstrap.to, written: bootstrap.written } : null, + elapsedMs, + stockDate: stock.statDate, + }; + } catch (error) { + const elapsedMs = Date.now() - startedAt; + await database.local.query( + `INSERT INTO return_monitor_job_log + (warehouse_key, biz_date, trigger_type, status, error_message, elapsed_ms) + VALUES (?, ?, ?, 'failed', ?, ?)`, + [WAREHOUSE_KEY, bizDate, trigger, String(error?.message || error).slice(0, 1000), elapsedMs] + ).catch(() => {}); + throw error; + } +} + +/** 是否今天已经比对过(避免每次打开页面都打业务库) */ +async function hasComparedToday(bizDate) { + const rows = await database.local.query( + `SELECT id, discrepancy_rows FROM return_monitor_job_log + WHERE warehouse_key = ? AND biz_date = ? AND status = 'success' + ORDER BY id DESC LIMIT 1`, + [WAREHOUSE_KEY, bizDate] + ); + return rows.length > 0; +} + +async function getLastJob() { + const rows = await database.local.query( + `SELECT biz_date, trigger_type, inbound_rows, stock_rows, cleared_rows, discrepancy_rows, + status, error_message, elapsed_ms, created_at + FROM return_monitor_job_log + WHERE warehouse_key = ? + ORDER BY id DESC LIMIT 1`, + [WAREHOUSE_KEY] + ); + const row = rows?.[0]; + if (!row) return null; + return { + bizDate: toDateOnly(row.biz_date), + triggerType: row.trigger_type, + inboundRows: toInt(row.inbound_rows, 0), + stockRows: toInt(row.stock_rows, 0), + clearedRows: toInt(row.cleared_rows, 0), + discrepancyRows: toInt(row.discrepancy_rows, 0), + status: row.status, + errorMessage: row.error_message || null, + elapsedMs: toInt(row.elapsed_ms, 0), + createdAt: row.created_at, + }; +} + +// --------------------------------------------------------------------------- +// 看板查询 +// --------------------------------------------------------------------------- + +let schemaReady = false; +async function ensureSchemaOnce() { + if (schemaReady) return; + const { ensureReturnMonitorSchema } = require('./returnMonitorSchema'); + await ensureReturnMonitorSchema(); + schemaReady = true; +} + +async function getKpi() { + const rows = await database.local.query( + `SELECT + COUNT(*) AS totalLines, + SUM(qty) AS totalQty, + SUM(CASE WHEN status = ? THEN qty ELSE 0 END) AS inStockQty, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS inStockLines, + SUM(CASE WHEN status = ? THEN qty ELSE 0 END) AS outboundQty, + SUM(CASE WHEN status = ? THEN qty ELSE 0 END) AS scrapQty + FROM return_monitor_inbound + WHERE warehouse_key = ? AND qty > 0`, + [STATUS_IN_STOCK, STATUS_IN_STOCK, STATUS_OUTBOUND, STATUS_SCRAP, WAREHOUSE_KEY] + ); + const row = rows?.[0] || {}; + return { + totalLines: toInt(row.totalLines, 0), + totalQty: toInt(row.totalQty, 0), + inStockQty: toInt(row.inStockQty, 0), + inStockLines: toInt(row.inStockLines, 0), + outboundQty: toInt(row.outboundQty, 0), + scrapQty: toInt(row.scrapQty, 0), + }; +} + +const AGE_BUCKETS = [ + { key: '0-3', label: '0-3 天', min: 0, max: 3 }, + { key: '4-7', label: '4-7 天', min: 4, max: 7 }, + { key: '8-15', label: '8-15 天', min: 8, max: 15 }, + { key: '15+', label: '15 天以上', min: 16, max: null }, +]; + +async function getAgeDistribution() { + const rows = await database.local.query( + `SELECT DATEDIFF(CURDATE(), inbound_date) AS ageDays, qty + FROM return_monitor_inbound + WHERE warehouse_key = ? AND status = ? AND qty > 0`, + [WAREHOUSE_KEY, STATUS_IN_STOCK] + ); + + const buckets = AGE_BUCKETS.map((bucket) => ({ ...bucket, lines: 0, qty: 0 })); + rows.forEach((row) => { + const age = Math.max(0, toInt(row.ageDays, 0)); + const qty = toInt(row.qty, 0); + const bucket = buckets.find((item) => age >= item.min && (item.max === null || age <= item.max)); + if (!bucket) return; + bucket.lines += 1; + bucket.qty += qty; + }); + return buckets.map(({ key, label, lines, qty }) => ({ key, label, lines, qty })); +} + +async function getOverdueCount(overdueDays) { + const rows = await database.local.query( + `SELECT COUNT(*) AS total, IFNULL(SUM(qty), 0) AS totalQty + FROM return_monitor_inbound + WHERE warehouse_key = ? AND status = ? AND qty > 0 + AND DATEDIFF(CURDATE(), inbound_date) > ?`, + [WAREHOUSE_KEY, STATUS_IN_STOCK, overdueDays] + ); + return { + count: toInt(rows?.[0]?.total, 0), + qty: toInt(rows?.[0]?.totalQty, 0), + }; +} + +async function getOverdueList(overdueDays, limit = 500) { + const rows = await database.local.query( + `SELECT id, return_no, product_code, sku_name, supplier, qty, inbound_date, express_no, + DATEDIFF(CURDATE(), inbound_date) AS ageDays + FROM return_monitor_inbound + WHERE warehouse_key = ? AND status = ? AND qty > 0 + AND DATEDIFF(CURDATE(), inbound_date) > ? + ORDER BY DATEDIFF(CURDATE(), inbound_date) DESC, qty DESC, id DESC + LIMIT ?`, + [WAREHOUSE_KEY, STATUS_IN_STOCK, overdueDays, Math.max(1, toInt(limit, 500))] + ); + return rows.map((row) => ({ + id: row.id, + returnNo: row.return_no, + productCode: row.product_code, + skuName: row.sku_name, + supplier: row.supplier, + qty: toInt(row.qty, 0), + inboundDate: toDateOnly(row.inbound_date), + expressNo: row.express_no, + ageDays: toInt(row.ageDays, 0), + })); +} + +async function getCodeSummary(limit = 1000) { + const rows = await database.local.query( + `SELECT t.product_code, + MAX(t.supplier) AS supplier, + SUM(t.qty) AS inStockQty, + COUNT(*) AS inStockLines, + MAX(DATEDIFF(CURDATE(), t.inbound_date)) AS maxAgeDays, + MIN(t.inbound_date) AS firstInboundDate, + MAX(s.stock_qty) AS stockQty + FROM return_monitor_inbound t + LEFT JOIN ( + SELECT product_code, stock_qty + FROM return_monitor_stock + WHERE warehouse_key = ? + AND stat_date = (SELECT MAX(stat_date) FROM return_monitor_stock WHERE warehouse_key = ?) + ) s ON s.product_code = t.product_code + WHERE t.warehouse_key = ? AND t.status = ? AND t.qty > 0 + GROUP BY t.product_code + HAVING SUM(t.qty) > 0 + ORDER BY maxAgeDays DESC, inStockQty DESC + LIMIT ?`, + [WAREHOUSE_KEY, WAREHOUSE_KEY, WAREHOUSE_KEY, STATUS_IN_STOCK, limit] + ); + return rows.map((row) => ({ + productCode: row.product_code, + supplier: row.supplier, + inStockQty: toInt(row.inStockQty, 0), + inStockLines: toInt(row.inStockLines, 0), + maxAgeDays: toInt(row.maxAgeDays, 0), + firstInboundDate: toDateOnly(row.firstInboundDate), + stockQty: row.stockQty === null || row.stockQty === undefined ? null : toInt(row.stockQty, 0), + })); +} + +async function getDiscrepancyList() { + const rows = await database.local.query( + `SELECT id, product_code, supplier, inbound_total, stock_qty, diff_qty, + fifo_plan_json, status, first_seen_date, last_seen_date, + resolution, resolved_by, resolved_at, remark + FROM return_monitor_discrepancy + WHERE warehouse_key = ? + ORDER BY diff_qty DESC, product_code ASC`, + [WAREHOUSE_KEY] + ); + return rows.map((row) => { + let plan = []; + let stockDate = null; + let inStockLines = 0; + let inStockQty = 0; + let planTruncated = false; + try { + const parsed = row.fifo_plan_json ? JSON.parse(row.fifo_plan_json) : null; + plan = parsed?.plan || []; + stockDate = parsed?.stockDate || null; + inStockLines = toInt(parsed?.inStockLines, 0); + inStockQty = toInt(parsed?.inStockQty, 0); + planTruncated = Boolean(parsed?.planTruncated); + } catch { + plan = []; + } + return { + id: row.id, + productCode: row.product_code, + supplier: row.supplier, + inboundTotal: toInt(row.inbound_total, 0), + stockQty: toInt(row.stock_qty, 0), + diffQty: toInt(row.diff_qty, 0), + fifoPlan: plan, + planTruncated, + inStockLines, + inStockQty, + status: row.status, + resolution: row.resolution || null, + firstSeenDate: toDateOnly(row.first_seen_date), + lastSeenDate: toDateOnly(row.last_seen_date), + stockDate, + resolvedBy: row.resolved_by || null, + resolvedAt: row.resolved_at || null, + remark: row.remark || null, + }; + }); +} + +/** 某个商品编码当前的全部在库入库行(用于差异清单的「手动指定」勾选) */ +async function getInStockLinesForCode({ productCode, page = 1, pageSize = 200 } = {}) { + const code = String(productCode || '').trim(); + if (!code) { + const error = new Error('缺少商品编码'); + error.statusCode = 400; + throw error; + } + + const countRows = await database.local.query( + `SELECT COUNT(*) AS total, IFNULL(SUM(qty), 0) AS totalQty + FROM return_monitor_inbound + WHERE warehouse_key = ? AND product_code = ? AND status = ? AND qty > 0`, + [WAREHOUSE_KEY, code, STATUS_IN_STOCK] + ); + + const limit = Math.min(MAX_PAGE_SIZE, Math.max(1, toInt(pageSize, 200))); + const offset = (Math.max(1, toInt(page, 1)) - 1) * limit; + + const rows = await database.local.query( + `SELECT id, return_no, qty, inbound_date, express_no, supplier, + DATEDIFF(CURDATE(), inbound_date) AS ageDays + FROM return_monitor_inbound + WHERE warehouse_key = ? AND product_code = ? AND status = ? AND qty > 0 + ORDER BY inbound_date ASC, id ASC + LIMIT ? OFFSET ?`, + [WAREHOUSE_KEY, code, STATUS_IN_STOCK, limit, offset] + ); + + return { + productCode: code, + total: toInt(countRows?.[0]?.total, 0), + totalQty: toInt(countRows?.[0]?.totalQty, 0), + rows: rows.map((row) => ({ + id: row.id, + returnNo: row.return_no, + qty: toInt(row.qty, 0), + inboundDate: toDateOnly(row.inbound_date), + expressNo: row.express_no, + supplier: row.supplier, + ageDays: toInt(row.ageDays, 0), + })), + }; +} + +/** 入库明细的过滤条件(列表与导出共用,保证口径一致) */ +function buildInboundWhere({ date, status, keyword }) { + const where = ['warehouse_key = ?', 'qty > 0']; + const params = [WAREHOUSE_KEY]; + + const normalizedDate = normalizeDateInput(date); + if (normalizedDate) { + where.push('inbound_date = ?'); + params.push(normalizedDate); + } + const normalizedStatus = String(status || '').trim(); + if (normalizedStatus) { + where.push('status = ?'); + params.push(normalizedStatus); + } + const kw = String(keyword || '').trim(); + if (kw) { + where.push('(product_code LIKE ? OR return_no LIKE ? OR IFNULL(express_no, \'\') LIKE ? OR IFNULL(supplier, \'\') LIKE ?)'); + const like = `%${kw}%`; + params.push(like, like, like, like); + } + + return { whereSql: where.join(' AND '), params }; +} + +const INBOUND_SELECT_COLUMNS = `id, return_no, product_code, sku_name, supplier, qty, inbound_date, express_no, order_no, + status, outbound_date, writeoff_mode, remark, + DATEDIFF(CURDATE(), inbound_date) AS ageDays`; + +const mapInboundRow = (row) => ({ + id: row.id, + returnNo: row.return_no, + productCode: row.product_code, + skuName: row.sku_name, + supplier: row.supplier, + qty: toInt(row.qty, 0), + inboundDate: toDateOnly(row.inbound_date), + expressNo: row.express_no, + orderNo: row.order_no, + status: row.status, + outboundDate: toDateOnly(row.outbound_date), + writeoffMode: row.writeoff_mode, + remark: row.remark, + ageDays: toInt(row.ageDays, 0), +}); + +async function getInboundDetail({ date, status, keyword, page = 1, pageSize = 200 }) { + const { whereSql, params } = buildInboundWhere({ date, status, keyword }); + + const countRows = await database.local.query( + `SELECT COUNT(*) AS total FROM return_monitor_inbound WHERE ${whereSql}`, + params + ); + + const limit = Math.min(MAX_PAGE_SIZE, Math.max(1, toInt(pageSize, 200))); + const offset = (Math.max(1, toInt(page, 1)) - 1) * limit; + + const rows = await database.local.query( + `SELECT ${INBOUND_SELECT_COLUMNS} + FROM return_monitor_inbound + WHERE ${whereSql} + ORDER BY inbound_date DESC, id DESC + LIMIT ? OFFSET ?`, + [...params, limit, offset] + ); + + return { + total: toInt(countRows?.[0]?.total, 0), + rows: rows.map(mapInboundRow), + }; +} + +async function getTrend(days = 14) { + const rows = await database.local.query( + `SELECT inbound_date, + COUNT(*) AS lineCount, + SUM(qty) AS qty, + SUM(CASE WHEN status = ? THEN qty ELSE 0 END) AS inStockQty + FROM return_monitor_inbound + WHERE warehouse_key = ? AND qty > 0 + AND inbound_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY inbound_date + ORDER BY inbound_date ASC`, + [STATUS_IN_STOCK, WAREHOUSE_KEY, Math.max(1, toInt(days, 14))] + ); + return rows.map((row) => ({ + date: toDateOnly(row.inbound_date), + lines: toInt(row.lineCount, 0), + qty: toInt(row.qty, 0), + inStockQty: toInt(row.inStockQty, 0), + })); +} + +async function getDashboard(params = {}) { + const date = normalizeDateInput(params.date) || todayInShanghai(); + const overdueDays = Math.max(1, toInt(params.overdueDays, DEFAULT_OVERDUE_DAYS)); + + const [kpi, ageDistribution, overdue, overdueList, codeSummary, discrepancies, detail, lastJob, stockDate, stockSyncedAt] = await Promise.all([ + getKpi(), + getAgeDistribution(), + getOverdueCount(overdueDays), + getOverdueList(overdueDays, params.overdueLimit || 500), + getCodeSummary(), + getDiscrepancyList(), + getInboundDetail({ + date: params.date === 'all' ? '' : date, + status: params.status, + keyword: params.keyword, + page: params.page, + pageSize: params.pageSize, + }), + getLastJob(), + loadLatestStockDate(), + loadLatestStockSyncedAt(), + ]); + + return { + warehouse: WAREHOUSE_DISPLAY, + warehouseKey: WAREHOUSE_KEY, + stockSource: STOCK_SOURCE_LABEL, + date, + overdueDays, + stockDate, + stockSyncedAt, + kpi: { + ...kpi, + discrepancyCodes: discrepancies.length, + overdueLines: overdue.count, + overdueQty: overdue.qty, + overdueListCapped: overdueList.length >= (toInt(params.overdueLimit, 500) || 500), + }, + ageDistribution, + overdueList, + codeSummary, + discrepancies, + detail, + lastJob, + gaps: GAPS, + }; +} + +// --------------------------------------------------------------------------- +// 导出 +// --------------------------------------------------------------------------- + +const EXPORT_MAX_ROWS = 100000; + +const DETAIL_EXPORT_COLUMNS = [ + { title: '退货单号', key: 'returnNo' }, + { title: '商品编码', key: 'productCode' }, + { title: '商品名称', key: 'skuName' }, + { title: '数量', key: 'qty' }, + { title: '供应商', key: 'supplier' }, + { title: '入库日期', key: 'inboundDate' }, + { title: '库龄(天)', key: 'ageDays' }, + { title: '状态', key: 'status' }, + { title: '出库日期', key: 'outboundDate' }, + { title: '核销方式', key: 'writeoffMode' }, + { title: '快递单号', key: 'expressNo' }, + { title: '来源订单号', key: 'orderNo' }, + { title: '备注', key: 'remark' }, +]; + +const OVERDUE_EXPORT_COLUMNS = [ + { title: '库龄(天)', key: 'ageDays' }, + { title: '商品编码', key: 'productCode' }, + { title: '商品名称', key: 'skuName' }, + { title: '数量', key: 'qty' }, + { title: '供应商', key: 'supplier' }, + { title: '入库日期', key: 'inboundDate' }, + { title: '退货单号', key: 'returnNo' }, + { title: '快递单号', key: 'expressNo' }, +]; + +const CODE_EXPORT_COLUMNS = [ + { title: '商品编码', key: 'productCode' }, + { title: '供应商', key: 'supplier' }, + { title: '在库件数', key: 'inStockQty' }, + { title: '在库行数', key: 'inStockLines' }, + { title: '现有库存', key: 'stockQty' }, + { title: '最长库龄(天)', key: 'maxAgeDays' }, + { title: '最早入库日期', key: 'firstInboundDate' }, +]; + +const DISCREPANCY_EXPORT_COLUMNS = [ + { title: '商品编码', key: 'productCode' }, + { title: '供应商', key: 'supplier' }, + { title: '退货入库件数', key: 'inboundTotal' }, + { title: '现有库存', key: 'stockQty' }, + { title: '推定已出库', key: 'diffQty' }, + { title: '库存快照日', key: 'stockDate' }, + { title: '首次发现', key: 'firstSeenDate' }, + { title: '最近发现', key: 'lastSeenDate' }, + { title: '在库行数', key: 'inStockLines' }, + { title: '在库件数', key: 'inStockQty' }, + { title: '状态', key: 'status' }, +]; + +const AGE_EXPORT_COLUMNS = [ + { title: '库龄区间', key: 'label' }, + { title: '入库行数', key: 'lines' }, + { title: '在库件数', key: 'qty' }, +]; + +const EXPORT_SCOPE_LABELS = { + all: '全部工作表', + detail: '退货入库明细', + overdue: '超期清单', + code: '按商品编码汇总', + discrepancy: '差异清单', + age: '库龄分布', +}; + +const toExportSheet = (name, columns, rows) => ({ + name, + header: columns.map((column) => column.title), + rows: (rows || []).map((row) => columns.map((column) => row[column.key])), +}); + +/** 导出用:拉取当前筛选条件下的全部入库明细(不分页) */ +async function listInboundRowsForExport({ date, status, keyword, limit = EXPORT_MAX_ROWS } = {}) { + const { whereSql, params } = buildInboundWhere({ date, status, keyword }); + const rows = await database.local.query( + `SELECT ${INBOUND_SELECT_COLUMNS} + FROM return_monitor_inbound + WHERE ${whereSql} + ORDER BY inbound_date DESC, id DESC + LIMIT ?`, + [...params, Math.max(1, toInt(limit, EXPORT_MAX_ROWS))] + ); + return rows.map(mapInboundRow); +} + +/** + * 组装看板导出数据集(供 api 层渲染成 xlsx / csv) + * scope = all | detail | overdue | code | discrepancy | age + */ +async function buildReturnMonitorExport({ date, overdueDays, keyword, status, scope = 'all' } = {}) { + const bizDate = normalizeDateInput(date) || todayInShanghai(); + const detailDate = String(date ?? '').trim().toLowerCase() === 'all' ? '' : bizDate; + const days = Math.max(1, toInt(overdueDays, DEFAULT_OVERDUE_DAYS)); + const normalizedScope = Object.prototype.hasOwnProperty.call(EXPORT_SCOPE_LABELS, scope) ? scope : 'all'; + const wants = (key) => normalizedScope === 'all' || normalizedScope === key; + + const sheets = []; + const counts = {}; + + if (wants('age')) { + const rows = await getAgeDistribution(); + sheets.push(toExportSheet('库龄分布', AGE_EXPORT_COLUMNS, rows)); + counts.age = rows.length; + } + if (wants('detail')) { + const rows = await listInboundRowsForExport({ date: detailDate, status, keyword }); + sheets.push(toExportSheet('退货入库明细', DETAIL_EXPORT_COLUMNS, rows)); + counts.detail = rows.length; + } + if (wants('overdue')) { + const rows = await getOverdueList(days, EXPORT_MAX_ROWS); + sheets.push(toExportSheet('超期清单', OVERDUE_EXPORT_COLUMNS, rows)); + counts.overdue = rows.length; + } + if (wants('code')) { + const rows = await getCodeSummary(EXPORT_MAX_ROWS); + sheets.push(toExportSheet('按商品编码汇总', CODE_EXPORT_COLUMNS, rows)); + counts.code = rows.length; + } + if (wants('discrepancy')) { + const rows = await getDiscrepancyList(); + sheets.push(toExportSheet('差异清单', DISCREPANCY_EXPORT_COLUMNS, rows)); + counts.discrepancy = rows.length; + } + + return { + scope: normalizedScope, + counts, + date: detailDate || null, + overdueDays: days, + fileName: `横林二仓客退数据监控_${EXPORT_SCOPE_LABELS[normalizedScope]}_${detailDate || '全部日期'}`, + sheets, + }; +} + +// --------------------------------------------------------------------------- +// 人工操作 +// --------------------------------------------------------------------------- + +/** + * 处理差异清单 + * mode = 'fifo' 按 FIFO 建议把整行核销为「已出库」 + * mode = 'manual' 按传入的 ids 指定要出库的行 + */ +async function resolveDiscrepancy({ productCode, mode = 'fifo', ids, outboundDate, operator, remark } = {}) { + await ensureSchemaOnce(); + const code = String(productCode || '').trim(); + if (!code) { + const error = new Error('缺少商品编码'); + error.statusCode = 400; + throw error; + } + + const bizDate = normalizeDateInput(outboundDate) || todayInShanghai(); + const rows = await database.local.query( + 'SELECT id, fifo_plan_json, status, diff_qty FROM return_monitor_discrepancy WHERE warehouse_key = ? AND product_code = ? LIMIT 1', + [WAREHOUSE_KEY, code] + ); + if (!rows.length) { + const error = new Error(`商品编码 ${code} 不在差异清单中`); + error.statusCode = 404; + throw error; + } + + let targetIds = []; + if (mode === 'manual') { + targetIds = (Array.isArray(ids) ? ids : []).map((id) => toInt(id, 0)).filter((id) => id > 0); + if (!targetIds.length) { + const error = new Error('手动指定模式下需要提供要核销的行 id'); + error.statusCode = 400; + throw error; + } + } else { + // FIFO:按入库日期从早到晚,把能覆盖差异件数的行实时重算后核销 + const diffQty = toInt(rows[0].diff_qty, 0); + const lines = await loadInStockLines(code); + targetIds = buildFifoLineIds(lines, diffQty); + if (!targetIds.length) { + const error = new Error('FIFO 建议为空,请改用手动指定'); + error.statusCode = 400; + throw error; + } + } + + const placeholders = targetIds.map(() => '?').join(', '); + const affected = await database.local.query( + `UPDATE return_monitor_inbound + SET status = ?, outbound_date = ?, writeoff_mode = ?, + remark = CONCAT('差异核销(', ?, '):', IFNULL(?, '')) + WHERE warehouse_key = ? AND product_code = ? AND status = ? AND id IN (${placeholders})`, + [ + STATUS_OUTBOUND, + bizDate, + mode === 'fifo' ? 'fifo' : 'manual', + mode === 'fifo' ? 'FIFO' : '手动指定', + remark ? String(remark).slice(0, 400) : null, + WAREHOUSE_KEY, + code, + STATUS_IN_STOCK, + ...targetIds, + ] + ); + + // 重算:核销后该编码可能已无差异 + await runReconciliation({ date: bizDate }); + await database.local.query( + `UPDATE return_monitor_discrepancy + SET status = '已确认', resolution = ?, resolved_by = ?, resolved_at = NOW() + WHERE warehouse_key = ? AND product_code = ?`, + [mode === 'fifo' ? 'fifo' : 'manual', operator ? String(operator).slice(0, 100) : null, WAREHOUSE_KEY, code] + ).catch(() => {}); + + return { productCode: code, mode, outboundDate: bizDate, clearedLines: Number(affected?.affectedRows || 0) }; +} + +/** 手工标记单行状态(主要用于「已报废」,以及人工纠偏) */ +async function updateInboundStatus({ id, status, outboundDate, operator, remark } = {}) { + await ensureSchemaOnce(); + const lineId = toInt(id, 0); + if (!lineId) { + const error = new Error('缺少行 id'); + error.statusCode = 400; + throw error; + } + const nextStatus = [STATUS_IN_STOCK, STATUS_OUTBOUND, STATUS_SCRAP].includes(String(status || '').trim()) + ? String(status).trim() + : ''; + if (!nextStatus) { + const error = new Error('状态只能是 在库 / 已出库 / 已报废'); + error.statusCode = 400; + throw error; + } + + const bizDate = normalizeDateInput(outboundDate) || todayInShanghai(); + const nextOutboundDate = nextStatus === STATUS_IN_STOCK ? null : bizDate; + const affected = await database.local.query( + `UPDATE return_monitor_inbound + SET status = ?, outbound_date = ?, writeoff_mode = 'mark', + remark = CONCAT('人工标记(', IFNULL(?, ''), '):', IFNULL(?, '')) + WHERE warehouse_key = ? AND id = ?`, + [ + nextStatus, + nextOutboundDate, + operator ? String(operator).slice(0, 60) : null, + remark ? String(remark).slice(0, 400) : null, + WAREHOUSE_KEY, + lineId, + ] + ); + + await runReconciliation({ date: bizDate }); + return { id: lineId, status: nextStatus, outboundDate: nextOutboundDate, affectedRows: Number(affected?.affectedRows || 0) }; +} + +module.exports = { + WAREHOUSE_KEY, + WAREHOUSE_DISPLAY, + DEFAULT_OVERDUE_DAYS, + todayInShanghai, + syncReturnInbound, + syncReturnInboundRange, + syncCurrentStock, + backfillHistory, + ensureHistoryBootstrapped, + runReconciliation, + runDailyCompare, + hasComparedToday, + getDashboard, + getInStockLinesForCode, + resolveDiscrepancy, + updateInboundStatus, + getTrend, + getLastJob, + buildReturnMonitorExport, +}; diff --git a/backend/src/services/returnStockWarehouseRegistry.js b/backend/src/services/returnStockWarehouseRegistry.js new file mode 100644 index 0000000..dda3178 --- /dev/null +++ b/backend/src/services/returnStockWarehouseRegistry.js @@ -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, +}; diff --git a/backend/src/services/returnStockWarningImport.js b/backend/src/services/returnStockWarningImport.js new file mode 100644 index 0000000..6f11469 --- /dev/null +++ b/backend/src/services/returnStockWarningImport.js @@ -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, +}; diff --git a/backend/src/services/returnStockWarningSchema.js b/backend/src/services/returnStockWarningSchema.js new file mode 100644 index 0000000..aa2c019 --- /dev/null +++ b/backend/src/services/returnStockWarningSchema.js @@ -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 }; diff --git a/backend/src/services/returnStockWarningService.js b/backend/src/services/returnStockWarningService.js new file mode 100644 index 0000000..d13a146 --- /dev/null +++ b/backend/src/services/returnStockWarningService.js @@ -0,0 +1,1245 @@ +'use strict'; + +/** + * 退货库存预警(客退数据监控)——通用服务,按仓库出数 + * + * 支撑 5 张看板:横林二仓 / 苏北 / 睢宁 / 山东 / 河南(见 returnStockWarehouseRegistry.js)。 + * 页面结构、口径、字段完全一致,差异只有「取哪个仓」「客退临时上架仓占哪个自定义库存位」, + * 由仓库注册表声明。所有对外函数都接受 options.warehouse(key 或 wms_co_id),不传则默认横林二仓。 + * + * 口径说明(2026-09-17 复核): + * + * - 数据范围:聚水潭「箱及仓位库存 · {仓库} · {该仓客退上架仓}」。 + * 仓位名逐仓不同(横林=客退临时上架仓 / 苏北=新苏北客退上架仓 / 睢宁=睢宁客退上架仓 / + * 山东=青岗集客退上架仓 / 河南=河南洛宁客退上架仓),见 returnStockWarehouseRegistry。 + * 报表副本(JSReportReplica)确实没有箱/仓位级库存:库内 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% 为空。 + * 因此用仓库级库存表 jst_inventoryInfo(按 wms_co_id 过滤)里该仓位绑定的自定义库存位近似, + * 粒度是「商品编码」而非「箱/仓位」。 + * - 创建时间(口径可切,默认 stockCreate): + * · stockCreate(默认):聚水潭库存记录创建时间 jst_inventoryInfo.createTime,即该编码**当前这条** + * 库存记录的建仓时间。横林二仓一个编码只有 1 行记录(2026-09-16 全量核对), + * SQL 里的 MIN 只是 GROUP BY 语法要求,取到的就是当前值。 + * 2026-09-17 实测:横林二仓 1,365 个编码里约 27%(369 个)取到的是 2025-12-03 的批量 + * 建账时间(一个时刻就有 268 个编码),不是真实上架时间;其余编码与聚水潭一致。 + * · firstInbound:该编码的最早入库时间(ERP 售后单 ∪ 奇门退款单 并集后 MIN),旧口径,值通常更早。 + * · 导入覆盖最优先:导入聚水潭「箱及仓位库存」导出文件后,创建时间直接取文件里的「创建日期」 + * (同一编码多箱时取最早的箱创建时间),此时与聚水潭完全一致。导入按仓库隔离。 + * + * ⚠️ 已删除的错误尝试(2026-09-17):曾上线「按退货单件数反推上架时间」并设为默认, + * 实测被证伪——反推假设「客退位在库 N 件 = 最近 N 件的退货入库」,但副本里退货表的留存 + * 窗口与真实退厂节奏对不上,横林二仓 1,365 个编码的反推结果**全部**落在最近一周 + * (2026-09-09~09-17),与 createTime 精确一致的数量为 0(上轮看到的「精确一致」是 + * 反推失败后回退到 createTime 的假象)。故整段下线,不要再实现类似反推。 + * + * 根因:聚水潭「箱及仓位库存」的「创建日期」= 仓位库存记录的创建时间,属于**仓位维度**, + * 副本里没有任何仓位级库存表(见上)。要拿到它只有两条路: + * ① IT 补同步——jst_packInventoryInfo 的表结构里本来就有 bin / bin_group / wms_co_id + * 三个字段,但 85,152 行**全部为空**(pack_type 全是 DefaultPack)。让同步程序按 + * 「装箱/仓位库存」接口把这三个字段取回来,即可按仓位精确取数(含创建时间); + * ② 导入聚水潭导出的 xlsx(页面已有按钮,按仓隔离,导入值优先级最高)。 + * 注:业务库 DATETIME 存的是北京时间,连接池已设 useUTC:false,页面显示值 = 库内原值, + * 不再出现历史上的 +8 小时偏差。 + * - 件数:默认只取本仓「客退上架仓」绑定的自定义库存位(横林/苏北/睢宁 = customize_qty_1, + * 山东 = customize_qty_2;各仓用的位不同,2026-09-16 全库扫描确认)——2026-09-17 用户明确 + * 「每个看板取聚水潭对应客退上架仓中的数据」,故不再叠加退货仓 return_qty(销退仓是另一个仓位, + * 页面/导出仍单列出来供核对)。可用 qtyBasis 切换比对,导入聚水潭文件后以导入件数为准。 + * - 在库天数:今天 − 创建时间,这是判断退厂的直接依据——「当天提取的库存数据里还有这条编码」, + * 就说明这批货还没退厂,所以在库天数就是它的滞留时长;哪天这条编码从库存里消失了, + * 就说明已经完成退厂,自然也不会出现在报表里。因此不再需要「出库日期 / 出库件数」。 + * - 出库日期 / 出库件数:已从页面与导出里去掉。副本里没有聚水潭「采购退货(退供出库)」数据 + * (jst_purchase 只有采购入库;全库 jst_purchaseItem.return_qty>0 仅 26 行且无横林二仓), + * 此前两列恒为「待同步」,对判断退厂没有信息量,保留只会误导。 + * - 单价 / 总金额:单价取商品资料 jst_sku 的成本价 cost_price(缺失或为 0 时回退 purchase_price), + * 总金额 = 单价 × 总库存。两个价格都没有的编码(多为虚拟编码)金额为空,页面显示「--」。 + * - 单价预警:在库龄预警之外的另一维度——单价 > 300 元 → 重点关注,200 ~ 300 元 → 关注, + * 阈值可在页面调;库龄看「压了多久」,单价看「每件压了多少钱」,两者叠加才看得出资金风险。 + * - 退厂时限(重点关注商品的处置要求):单价 > 重点关注线(默认 300 元)的编码, + * 要求入库后 N 天内完成库存退厂,默认 N = 7 天(页面可调)。 + * 在库天数 > N → overdue(已超期,超 N 天);≤ N → pending(剩余 N − 在库天数 天); + * done(已退厂,保留分支:退厂完成即从库存消失,一般不会出现在报表里);非重点关注 → none(不适用)。 + */ + +const cloudDb = require('../config/cloudDatabase'); +const database = require('../config/database'); +const { + WAREHOUSE_LIST, + RETURN_SLOT_COLUMNS, + DEFAULT_WAREHOUSE_KEY, + resolveWarehouse, + resolveWarehouseKey, + getWarehouseStockLabel, +} = require('./returnStockWarehouseRegistry'); + +// 默认仓库(横林二仓):所有对外函数在不传 warehouse 时都走它 +const DEFAULT_WAREHOUSE = resolveWarehouse(DEFAULT_WAREHOUSE_KEY); +// 兼容旧引用:默认仓的标签 / 销退仓名 / 仓编码 +const WAREHOUSE_LABEL = getWarehouseStockLabel(DEFAULT_WAREHOUSE); +const WAREHOUSE_DB = DEFAULT_WAREHOUSE.returnWarehouseDb; +const HENLIN2_WMS_CO_ID = DEFAULT_WAREHOUSE.wmsCoId; + +// 件数口径(副本能给出的候选,用于与聚水潭比对时切换) +// 默认 = 「仅该仓客退上架仓占用的那一位自定义库存」:2026-09-17 用户明确 +// 「每个看板取聚水潭对应客退上架仓中的数据」,所以默认不再叠加退货仓 return_qty +// (return_qty 是「销退仓」的库存,与客退上架仓是两个仓位,页面仍单独列出来供核对)。 +// value 是稳定的键,label 按仓库动态生成——因为各仓用的自定义库存位不同 +// (横林/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),写死列名会让山东的 +// 选项文案与实际取的列对不上。 +const QTY_BASIS_VALUES = ['c1', 'rt_c1', 'rt', 'all']; +const DEFAULT_QTY_BASIS = 'c1'; + +const buildQtyBasis = (warehouse = DEFAULT_WAREHOUSE) => { + const slot = warehouse.returnSlot; // customize_qty_1 / customize_qty_2 / ... + const bin = warehouse.returnBinName || '客退上架仓'; + return [ + { + value: 'c1', + label: `仅客退上架仓(${slot})`, + note: `默认。即聚水潭「${bin}」这一个仓位的库存,对应副本列 ${slot}`, + }, + { + value: 'rt_c1', + label: `客退上架仓 + 退货仓(${slot} + return_qty)`, + note: '若聚水潭把销退仓的货也算进该仓位,用这个口径', + }, + { value: 'rt', label: '仅退货仓(return_qty)', note: '聚水潭「销售退仓 / 销退仓」口径' }, + { value: 'all', label: '退货 + 客退上架 + 在途(+ in_qty)', note: '含在途,一般偏大' }, + ]; +}; + +// 默认仓的口径选项(对外导出保持兼容) +const QTY_BASIS = buildQtyBasis(DEFAULT_WAREHOUSE); + +// 创建时间口径(页面可切换,便于与聚水潭「箱及仓位库存 - 创建日期」逐条比对) +// +// ⚠️ 2026-09-17 复盘:曾短暂上线过「按退货单反推上架时间」口径,已**下线删除**。 +// 原因:该反推假设「当前客退位在库件数 = 最近 N 件的退货入库」,但副本里两张退货表 +// (jst_returnAndRefundItem 售后单、jst_qmcust_refundlist_item 奇门退款单)的留存窗口 +// 与实际退厂节奏都对不上,实测在横林二仓 1,365 个编码上: +// · 与 createTime 精确一致(精确到秒)的:0 个(上一轮看到的「精确一致」其实是 +// 反推失败后回退到 createTime 的假象) +// · 反推结果全部落在最近 1 周内(2026-09-09 ~ 09-17),与真实上架时间无关 +// 结论:副本不具备还原「仓位库存记录创建时间」的数据,不要再用反推。 +const CREATED_AT_BASIS_STOCK_CREATE = 'stockCreate'; +const CREATED_AT_BASIS_FIRST_INBOUND = 'firstInbound'; +const CREATED_AT_BASIS = [ + { + value: CREATED_AT_BASIS_STOCK_CREATE, + label: '聚水潭库存记录创建时间', + note: + 'jst_inventoryInfo.createTime。注意 2026-09-17 实测:约 27~33% 的编码(2025-12-03 批量建账的老库存)' + + '取到的是建账时间,不是真实上架时间;其余编码与聚水潭一致', + }, + { + value: CREATED_AT_BASIS_FIRST_INBOUND, + label: '最早入库时间(旧口径)', + note: 'ERP 售后单 ∪ 奇门退款单取 MIN,通常比聚水潭早很多', + }, +]; +const DEFAULT_CREATED_AT_BASIS = CREATED_AT_BASIS_STOCK_CREATE; + +// 「期初批量建账」识别:stockCreate 口径下取到这些时刻的编码,其创建时间**不是**真实上架时间, +// 而是聚水潭期初建账时间(2026-09-17 实测横林二仓:2025-12-03 就一个时刻 268 个编码、约 27%)。 +// 用一个独立 source 标出来(不放进可选项,只用于「数据来源」列与导出的标注)。 +const CREATED_AT_BASIS_STOCK_CREATE_BATCH = 'stockCreateBatch'; +const STOCK_CREATE_BATCH_DAYS = ['2025-12-03']; +const isBatchStockCreate = (raw) => { + const text = formatDateTime(raw); + return !!text && STOCK_CREATE_BATCH_DAYS.some((day) => text.startsWith(day)); +}; + +const CREATED_AT_SOURCE_LABELS = { + imported: '聚水潭导入', + [CREATED_AT_BASIS_STOCK_CREATE]: '聚水潭库存记录', + [CREATED_AT_BASIS_STOCK_CREATE_BATCH]: '聚水潭库存记录(期初建账时间,非真实上架时间)', + [CREATED_AT_BASIS_FIRST_INBOUND]: 'ERP最早入库', +}; + +// 库龄预警阈值(在库天数,页面可自己调) +// 在库天数 > alertDays → 红色预警(默认 > 15 天) +// 在库天数 > focusDays → 橙色关注(默认 > 7 天) +// 之所以按在库天数分级:业务库的 ope_clear_task.alert(清仓任务预警)覆盖率只有 18% +// (1,788 个编码里仅 324 个有值),且语义是「清仓任务是否异常」,不适合做本页面的库存预警。 +const DEFAULT_FOCUS_DAYS = 7; +const DEFAULT_ALERT_DAYS = 15; +const MAX_THRESHOLD_DAYS = 3650; + +// 单价预警阈值(商品资料单价,元;页面可自己调) +// 单价 > priceHighMin → 重点关注(默认 > 300 元) +// 单价 ≥ priceFocusMin 且 ≤ priceHighMin → 关注(默认 200 ~ 300 元) +// 单价 < priceFocusMin → 正常 +// 按单价单独预警的理由:单价高的客退库存单件占资金多,同样压 1 件, +// 占用的资金可能是低单价编码的几十倍,只看件数会把资金风险看平。 +const DEFAULT_PRICE_FOCUS_MIN = 200; +const DEFAULT_PRICE_HIGH_MIN = 300; +const MAX_PRICE_THRESHOLD = 1000000; + +// 退厂时限(重点关注商品的处置要求,页面可调) +// 单价 > priceHighMin 的编码(重点关注)→ 要求入库后 N 天内完成库存退厂,默认 7 天 +// 在库天数 > N → 已超期;≤ N → 剩余(N − 在库天数)天;已出库 → 已退厂 +// 为什么要单独设这个时限:这批编码单件占用资金最多,压在仓里越久越容易变成呆滞; +// 给它一个硬性退厂窗口,页面按「剩余 / 超期」倒计时,便于催办。 +const DEFAULT_RETURN_LIMIT_DAYS = 7; +const MAX_RETURN_LIMIT_DAYS = 3650; + +const WARNING_LEVEL_OPTIONS = [ + { value: 'all', label: '全部等级' }, + { value: 'alert', label: '红色预警' }, + { value: 'attention', label: '关注及以上' }, + { value: 'focus', label: '关注' }, + { value: 'normal', label: '正常' }, +]; + +const WARNING_LABELS = { + alert: '红色预警', + focus: '关注', + normal: '正常', + unknown: '未知', +}; + +// 单价预警分级标签(与前端 StockWarning.jsx / dashboard 保持一致) +const PRICE_WARNING_LABELS = { + high: '重点关注', + focus: '关注', + normal: '正常', + unknown: '无单价', +}; + +const PRICE_LEVEL_RANK = { high: 3, focus: 2, normal: 1, unknown: 0 }; + +// 退厂时限状态标签 / 排序权重(与前端 StockWarning.jsx 保持一致) +const RETURN_DEADLINE_LABELS = { + overdue: '已超期', + pending: '剩余', + done: '已退厂', + none: '不适用', +}; +const RETURN_DEADLINE_RANK = { overdue: 3, pending: 2, done: 1, none: 0 }; + +// 单价预警筛选(导出时服务端按同一口径过滤;页面里的表格是前端即时过滤) +const PRICE_LEVEL_OPTIONS = [ + { value: 'all', label: '全部价格' }, + { value: 'high', label: '重点关注(> 300 元)' }, + { value: 'attention', label: '关注及以上' }, + { value: 'focus', label: '关注(200 ~ 300 元)' }, + { value: 'normal', label: '正常(< 200 元)' }, + { value: 'unknown', label: '无单价' }, +]; + +/** + * 口径说明(按仓库生成)——每个看板的「已知口径差异」文案 + */ +const buildKnownGaps = (warehouse = DEFAULT_WAREHOUSE) => { + const slot = warehouse.returnSlot; + const gaps = [ + `「箱及仓位库存 · ${warehouse.warehouseName} · ${warehouse.returnBinName}」是箱/仓位级数据,报表副本里没有该维度(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% 为空),因此当前用 jst_inventoryInfo 仓库级库存(wms_co_id=${warehouse.wmsCoId} ${warehouse.warehouseName})里该仓位绑定的自定义库存位 ${slot} 近似,粒度是「商品编码」而非「箱/仓位」。要逐箱还原(含真实箱上架时间)只能导入聚水潭导出的文件。`, + '创建时间:取聚水潭库存记录创建时间(jst_inventoryInfo.createTime)。它与聚水潭页面的「创建日期」**只能部分一致**,原因是聚水潭的「创建日期」属于箱/仓位维度:一个编码有几箱就有几条记录、各有各的上架时间,而副本里没有仓位级库存表,本页只能按编码取一个值。实测横林二仓 1,365 个编码里约 27%(369 个)取到的是 2025-12-03 的**期初批量建账时间**(一个时刻就有 268 个编码),比真实上架时间早很多,这类编码的在库天数会偏大、偏保守;其余约 73% 的编码该值与聚水潭一致。(2026-09-17 曾上线「按退货单反推上架时间」口径试图修正这批值,经 1,365 个编码全量验证为**错误**——反推结果全部落在最近一周,已下线删除。)', + '创建时间的完整解决方案(二选一):① 让 IT 补同步——副本表 jst_packInventoryInfo 的结构里本来就有 bin(仓位)、bin_group、wms_co_id 三个字段,但现有 85,152 行的这三个字段**全部为空**(pack_type 全是 DefaultPack),说明同步时没取「装箱/仓位库存」的仓位信息;补上之后本页就能直接按「仓位 = ' + warehouse.returnBinName + '」精确取数,创建时间即聚水潭页面的「创建日期」。② 用页面右上「导入」按钮上传聚水潭「箱及仓位库存」导出的 xlsx,创建时间直接取文件里的「创建日期」(同一编码多箱取最早箱时间、件数求和),导入值优先级最高、按仓隔离、不覆盖其他看板。', + `件数:默认只取「客退上架仓」这一位——${slot}(各仓用的位不一样:横林二仓 / 新苏北仓 / 睢宁仓用 customize_qty_1,山东青岗集仓用 customize_qty_2),不再叠加退货仓 return_qty(那是「销退仓」的库存,页面/导出里仍单列出来供核对)。若与聚水潭「${warehouse.returnBinName}」对不上,可在「件数口径」里切「客退上架仓 + 退货仓」比对,或直接导入聚水潭导出文件为准。`, + '在库天数与退厂判定:报表只展示「在库天数」(今天 − 创建时间)。判断退厂不依赖出库表——当天提取的库存数据里还有这条编码,就说明没退厂,在库天数就是它的滞留时长;哪天它从库存里消失,就说明已退厂,自然也不在报表里。「出库日期 / 出库件数」两列因此已从页面和导出中移除(副本里没有聚水潭「采购退货(退供出库)」数据,全库 207 张表中只有 jst_purchase / jst_purchaseItem 采购入库单,item_type 全为「成品」、无退货单据类型;采购明细里 return_qty>0 仅 26 行、902 件且不在本仓;ope_clear_pool / ope_clear_task 是清货任务,也不是退供出库)。', + '单价 / 总金额:单价取商品资料 jst_sku 的成本价(cost_price),成本价缺失或为 0 时回退采购价(purchase_price);总金额 = 单价 × 总库存(即「客退临时上架仓」的库存金额)。商品资料里两个价格都没有的编码(多为虚拟编码,如 HBZBF)单价与总金额显示「--」,且不计入顶部「库存总金额」,页面会单独提示这类编码的数量。', + '单价预警:按商品单价单独分级,默认「单价 > 300 元 → 重点关注」「200 ~ 300 元 → 关注」「< 200 元 → 正常」,阈值可在页面上直接调。它与库龄预警是两个互补维度——库龄看「货压了多久」,单价看「每件压了多少钱」,两者叠加才能看出真正占资金的那批编码。', + '退厂时限:单价 > 重点关注线(默认 300 元)的编码,要求入库后 7 天内完成库存退厂(时限可在页面调)。页面按「在库天数」对比时限,给出「已超期 N 天 / 剩余 N 天」,并在顶部单独提示这批编码的处置要求。「已退厂」的判定是「该编码已从当天库存中消失」(不再依赖出库表),一旦退厂完成,这条编码自然就不会再出现在报表里。', + ]; + if (!warehouse.hasReturnStock) { + gaps.unshift( + `⚠ 本仓(${warehouse.warehouseName},wms_co_id=${warehouse.wmsCoId})当天在副本里没有客退在库数据:仓库存在、退货单也有(「${warehouse.returnWarehouseDb}」),但没有编码落在客退库存位上,所以表格是空的。等聚水潭侧有客退库存同步进来后,本页会自动出现内容。` + ); + } + return gaps; +}; + +// 默认仓的口径说明(对外导出保持兼容) +const KNOWN_GAPS = buildKnownGaps(DEFAULT_WAREHOUSE); + +const formatLocalDate = (value = new Date()) => { + const date = value instanceof Date ? value : new Date(value); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const formatDateTime = (value) => { + if (value === null || value === undefined || value === '') return ''; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + const ss = String(date.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${d} ${hh}:${mm}:${ss}`; +}; + +const diffDays = (fromValue, toValue) => { + if (!fromValue) return null; + const from = fromValue instanceof Date ? fromValue : new Date(fromValue); + if (Number.isNaN(from.getTime())) return null; + const to = toValue instanceof Date ? toValue : new Date(toValue); + if (Number.isNaN(to.getTime())) return null; + const fromDay = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate()); + const toDay = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()); + return Math.round((toDay - fromDay) / 86400000); +}; + +// 客退库存(按仓库):把各口径的件数列都取出来,口径选择在 JS 层做,便于即时切换比对 +// 同时带上聚水潭库存记录创建时间 createTime(默认创建时间口径) +// +// 三个自定义库存位全部取出来(横林/苏北/睢宁用 customize_qty_1,山东用 customize_qty_2), +// 具体用哪一位由仓库注册表的 returnSlot 决定,见 calcQty。 +// +// 关于 MIN(createTime):这里用 MIN 只是 GROUP BY 的语法要求,**不是「取最早」**。 +// 2026-09-16 全量核对过:横林二仓(wms_co_id=15033302)在 jst_inventoryInfo 里 +// 12,425 个编码**每个都只有 1 行**记录,MIN 与 MAX 的差异编码数 = 0, +// 所以 MIN 取到的就是该编码当前那条库存记录本身的创建时间,与聚水潭一致。 +// 若将来副本改成按箱/仓位同步(一个编码多行),这里要重新确认「取哪一箱」的口径。 +const buildStockCte = (warehouse = DEFAULT_WAREHOUSE) => { + const slotExprs = RETURN_SLOT_COLUMNS + .map((col) => `SUM(ISNULL(i.${col}, 0)) AS ${col}`); + const havingExpr = ['return_qty', warehouse.returnSlot] + .filter((col, index, arr) => arr.indexOf(col) === index) + .map((col) => { + if (col === 'return_qty') return 'ISNULL(i.return_qty, 0)'; + return `ISNULL(i.${col}, 0)`; + }); + return ` + stock AS ( + SELECT + LTRIM(RTRIM(CONVERT(NVARCHAR(120), i.sku_id))) AS product_code, + SUM(ISNULL(i.return_qty, 0)) AS return_qty, + ${slotExprs.join(',\n ')}, + SUM(ISNULL(i.in_qty, 0)) AS in_qty, + MIN(i.createTime) AS stock_create_time + FROM dbo.jst_inventoryInfo i + WHERE i.wms_co_id = ${warehouse.wmsCoId} + AND i.sku_id IS NOT NULL + AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), i.sku_id))) <> N'' + GROUP BY LTRIM(RTRIM(CONVERT(NVARCHAR(120), i.sku_id))) + HAVING SUM(${havingExpr.join(' + ')}) > 0 + )`; +}; + +// 该商品编码的最早入库时间(ERP 售后单 ∪ 奇门退款单) +// 只针对 stock 里已有的编码求 MIN,避免对两张退货明细大表做全量 GROUP BY +const buildFirstInboundCte = (warehouse = DEFAULT_WAREHOUSE) => ` + first_in AS ( + SELECT product_code, MIN(inbound_time) AS first_inbound_time + FROM ( + SELECT + LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) AS product_code, + COALESCE(it.receive_date, i.as_date) AS inbound_time + FROM dbo.jst_returnAndRefundInfo i + JOIN dbo.jst_returnAndRefundItem it ON it.as_id = i.as_id + WHERE i.warehouse = N'${warehouse.returnWarehouseDb}' + AND it.sku_id IN (SELECT product_code FROM stock) + UNION ALL + SELECT + LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))) AS product_code, + COALESCE(qit.receive_date, q.as_date) AS inbound_time + 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.returnWarehouseDb}' + AND qit.sku_id IN (SELECT product_code FROM stock) + ) u + WHERE u.inbound_time IS NOT NULL + GROUP BY u.product_code + )`; + +// first_in 只在选用「最早入库时间」口径时才拼进 SQL: +// 默认口径(聚水潭库存记录创建时间)不需要扫两张退货明细大表,快很多 +const buildFullSql = (createdAtBasis = DEFAULT_CREATED_AT_BASIS, warehouse = DEFAULT_WAREHOUSE) => { + const useFirstInbound = createdAtBasis === CREATED_AT_BASIS_FIRST_INBOUND; + const ctes = [buildStockCte(warehouse)]; + if (useFirstInbound) ctes.push(buildFirstInboundCte(warehouse)); + const selectColumns = [ + 's.product_code AS [productCode]', + 's.return_qty AS [returnQty]', + 's.customize_qty_1 AS [customizeQty1]', + 's.customize_qty_2 AS [customizeQty2]', + 's.customize_qty_3 AS [customizeQty3]', + 's.in_qty AS [inQty]', + 's.stock_create_time AS [stockCreateTime]', + ]; + if (useFirstInbound) selectColumns.push('f.first_inbound_time AS [firstInboundTime]'); + selectColumns.push("ISNULL(sk.supplier_name, N'') AS [supplier]"); + // 单价取自商品资料 jst_sku:成本价优先,缺失/为 0 时回退采购价(见 pickUnitPrice) + selectColumns.push('sk.cost_price AS [costPrice]'); + selectColumns.push('sk.purchase_price AS [purchasePrice]'); + const joins = ['LEFT JOIN dbo.jst_sku sk ON sk.sku_id = s.product_code']; + if (useFirstInbound) joins.push('LEFT JOIN first_in f ON f.product_code = s.product_code'); + + return ` + WITH ${ctes.join(',\n ')} + SELECT + ${selectColumns.join(',\n ')} + FROM stock s + ${joins.join('\n ')}`; +}; + +// --------------------------------------------------------------------------- +// 副本数据缓存:全量只有 1,788 行,但 first_in 要扫两张退货明细大表,单次查询 3~10s。 +// 口径切换 / 排序 / 分页 / 关键字全部在内存里做,因此缓存原始行即可, +// 只有显式 refresh 才回源,避免翻页或换口径时反复打库。 +// --------------------------------------------------------------------------- +// 库存表的实际拉取时间(副本同步进来的 updateTime),用于页面展示「库存更新时间」 +const buildStockSyncedSql = (warehouse = DEFAULT_WAREHOUSE) => ` + SELECT MAX(updateTime) AS [syncedAt] + FROM dbo.jst_inventoryInfo + WHERE wms_co_id = ${warehouse.wmsCoId}`; + +const CACHE_TTL_MS = 10 * 60 * 1000; +// 缓存键 = 仓库 + 创建时间口径(两者都会改变 SQL / 取到的行) +const buildCacheKey = (warehouse, basis) => `${warehouse.key}|${basis}`; +const rowCache = new Map(); +const inflightCache = new Map(); +// 「库存更新时间」也要按仓库分,否则切仓会串号 +const stockSyncedAtState = new Map(); + +async function loadReplicaRows({ + refresh = false, + createdAtBasis = DEFAULT_CREATED_AT_BASIS, + warehouse = DEFAULT_WAREHOUSE, +} = {}) { + const basis = resolveCreatedAtBasis(createdAtBasis); + const key = buildCacheKey(warehouse, basis); + const cached = rowCache.get(key); + if (!refresh && cached && cached.rows && Date.now() - cached.at < CACHE_TTL_MS) { + return cached.rows; + } + const running = inflightCache.get(key); + if (running) return running; + + const task = Promise.all([ + cloudDb.query(buildFullSql(basis, warehouse), {}, { requestTimeout: 300000 }), + cloudDb.query(buildStockSyncedSql(warehouse), {}, { requestTimeout: 120000 }), + ]) + .then(([rows, syncRows]) => { + rowCache.set(key, { at: Date.now(), rows: rows || [] }); + const syncedAt = formatDateTime(syncRows?.[0]?.syncedAt); + if (syncedAt) stockSyncedAtState.set(warehouse.key, { at: Date.now(), value: syncedAt }); + inflightCache.delete(key); + return rowCache.get(key).rows; + }) + .catch((error) => { + inflightCache.delete(key); + throw error; + }); + + inflightCache.set(key, task); + return task; +} + +/** 副本库存表最近一次同步时间(YYYY-MM-DD HH:mm:ss),页面「库存更新时间」用 */ +async function loadStockSyncedAt({ refresh = false, warehouse = DEFAULT_WAREHOUSE } = {}) { + const state = stockSyncedAtState.get(warehouse.key); + if (!refresh && state?.value && Date.now() - state.at < CACHE_TTL_MS) { + return state.value; + } + try { + await loadReplicaRows({ refresh, warehouse }); + } catch (error) { + return state?.value || ''; + } + return stockSyncedAtState.get(warehouse.key)?.value || ''; +} + +/** 启动时后台预热,避免用户第一次点开页面时等几十秒(五个仓库都预热,默认口径) */ +async function warmReturnStockWarningCache() { + // 串行而非并发:预热是后台任务,没必要在启动瞬间同时打五条库 + for (const warehouse of WAREHOUSE_LIST) { + try { + // eslint-disable-next-line no-await-in-loop + await loadReplicaRows({ refresh: true, warehouse }); + } catch (error) { + // 预热失败不影响启动,用户访问时会再试一次 + } + } +} + +const resolveQtyBasis = (value) => { + const key = String(value || '').trim(); + return QTY_BASIS_VALUES.includes(key) ? key : DEFAULT_QTY_BASIS; +}; + +const resolveCreatedAtBasis = (value) => { + const key = String(value || '').trim(); + return CREATED_AT_BASIS.some((item) => item.value === key) ? key : DEFAULT_CREATED_AT_BASIS; +}; + +const resolveThreshold = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(MAX_THRESHOLD_DAYS, parsed); +}; + +/** + * 预警阈值归一:关注线不能高于预警线,否则低阈值会把高阈值吃掉 + */ +const resolveThresholds = (options = {}) => { + const focusRaw = resolveThreshold(options.focusDays, DEFAULT_FOCUS_DAYS); + const alertRaw = resolveThreshold(options.alertDays, DEFAULT_ALERT_DAYS); + const alertDays = Math.max(focusRaw, alertRaw); + return { focusDays: Math.min(focusRaw, alertDays), alertDays }; +}; + +// 「大于」判定:在库天数 > 预警天数 才算红色预警(默认 > 15 天) +const resolveWarningLevel = (stockDays, focusDays, alertDays) => { + if (stockDays === null || stockDays === undefined) return 'unknown'; + if (stockDays > alertDays) return 'alert'; + if (stockDays > focusDays) return 'focus'; + return 'normal'; +}; + +const matchesWarningLevel = (level, filter) => { + if (!filter || filter === 'all') return true; + if (filter === 'alert') return level === 'alert'; + if (filter === 'focus') return level === 'focus'; + if (filter === 'attention') return level === 'alert' || level === 'focus'; + if (filter === 'normal') return level === 'normal'; + return true; +}; + +const resolveWarningLevelFilter = (value) => { + const key = String(value || '').trim(); + return WARNING_LEVEL_OPTIONS.some((item) => item.value === key) ? key : 'all'; +}; + +const resolvePriceThreshold = (value, fallback) => { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(MAX_PRICE_THRESHOLD, Math.round(parsed * 100) / 100); +}; + +/** 单价阈值归一:关注线不能高于重点关注线 */ +const resolvePriceThresholds = (options = {}) => { + const focusRaw = resolvePriceThreshold(options.priceFocusMin, DEFAULT_PRICE_FOCUS_MIN); + const highRaw = resolvePriceThreshold(options.priceHighMin, DEFAULT_PRICE_HIGH_MIN); + const priceHighMin = Math.max(focusRaw, highRaw); + return { priceFocusMin: Math.min(focusRaw, priceHighMin), priceHighMin }; +}; + +/** + * 单价预警分级(「大于」判定,与页面文案一致): + * 单价 > priceHighMin → high(重点关注,默认 > 300 元) + * 单价 ≥ priceFocusMin → focus(关注,默认 200 ~ 300 元) + * 其余 → normal + * 拿不到单价(商品资料缺成本价/采购价)→ unknown + */ +const resolvePriceLevel = (unitPrice, priceFocusMin, priceHighMin) => { + if (unitPrice === null || unitPrice === undefined) return 'unknown'; + if (unitPrice > priceHighMin) return 'high'; + if (unitPrice >= priceFocusMin) return 'focus'; + return 'normal'; +}; + +const matchesPriceLevel = (level, filter) => { + if (!filter || filter === 'all') return true; + if (filter === 'high') return level === 'high'; + if (filter === 'focus') return level === 'focus'; + if (filter === 'attention') return level === 'high' || level === 'focus'; + if (filter === 'normal') return level === 'normal'; + if (filter === 'unknown') return level === 'unknown'; + return true; +}; + +const resolvePriceLevelFilter = (value) => { + const key = String(value || '').trim(); + const allowed = PRICE_LEVEL_OPTIONS.some((item) => item.value === key) || key === 'unknown'; + return allowed ? key : 'all'; +}; + +const resolveReturnLimitDays = (value) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_RETURN_LIMIT_DAYS; + return Math.min(MAX_RETURN_LIMIT_DAYS, parsed); +}; + +/** + * 退厂时限判定(只对「重点关注」编码生效): + * 非重点关注 → none(不适用) + * 已出库 → done(已退厂,保留分支:退厂完成即从库存消失,一般不会出现在报表里) + * 在库天数 > 退厂时限 → overdue,returnRemainDays = 超期天数 + * 其余 → pending,returnRemainDays = 剩余天数(时限 − 在库天数) + * 拿不到创建时间(算不出在库天数)时按 pending 处理,剩余天数留空,页面提示「待补创建时间」。 + */ +const resolveReturnDeadline = ({ unitPriceLevel, stockDays, isOutbound, returnLimitDays }) => { + if (unitPriceLevel !== 'high') return { status: 'none', remainDays: null }; + if (isOutbound) return { status: 'done', remainDays: null }; + if (stockDays === null || stockDays === undefined) return { status: 'pending', remainDays: null }; + if (stockDays > returnLimitDays) return { status: 'overdue', remainDays: stockDays - returnLimitDays }; + return { status: 'pending', remainDays: returnLimitDays - stockDays }; +}; + +/** 退厂时限的一句话文案(导出 Excel 用,与页面表格显示口径一致) */ +const formatReturnDeadlineLabel = (status, remainDays) => { + if (status === 'overdue') return remainDays === null ? '已超期' : `已超期 ${remainDays} 天`; + if (status === 'pending') return remainDays === null ? '剩余天数未知' : `剩余 ${remainDays} 天`; + if (status === 'done') return '已退厂'; + return '—'; +}; + +// 自定义库存位列名 → 行字段名(SQL 里 select 出来的别名) +const RETURN_SLOT_FIELD_MAP = { + customize_qty_1: 'customizeQty1', + customize_qty_2: 'customizeQty2', + customize_qty_3: 'customizeQty3', +}; + +/** 本仓「客退临时上架仓」的件数(取该仓注册表里声明的那个自定义库存位) */ +const pickReturnSlotQty = (row, warehouse = DEFAULT_WAREHOUSE) => { + const field = RETURN_SLOT_FIELD_MAP[warehouse.returnSlot] || 'customizeQty1'; + return Number(row[field] || 0); +}; + +/** + * 件数口径(各仓的「客退临时上架仓」用的库存位不同,因此 c1/c2 都是相对本仓而言): + * rt_c1(默认)→ return_qty + 本仓客退位 + * c1 → 仅本仓客退位 + * rt → 仅退货仓 return_qty + * all → return_qty + 三个自定义位 + 在途 in_qty + */ +const calcQty = (row, basis, warehouse = DEFAULT_WAREHOUSE) => { + const rt = Number(row.returnQty || 0); + const slotQty = pickReturnSlotQty(row, warehouse); + const inQty = Number(row.inQty || 0); + if (basis === 'c1') return slotQty; + if (basis === 'rt') return rt; + if (basis === 'all') { + const allSlots = RETURN_SLOT_COLUMNS.reduce((sum, col) => sum + Number(row[RETURN_SLOT_FIELD_MAP[col]] || 0), 0); + return rt + allSlots + inQty; + } + return rt + slotQty; +}; + +// --------------------------------------------------------------------------- +// 单价口径:商品资料 jst_sku 的成本价(cost_price)优先,缺失或为 0 时回退采购价(purchase_price)。 +// 总金额 = 单价 × 总库存。两个价格都拿不到的编码(多为虚拟编码,如 HBZBF)金额为 null,页面显示「--」。 +// --------------------------------------------------------------------------- +const UNIT_PRICE_BASIS_LABEL = '成本价 cost_price(缺失时回退采购价 purchase_price)'; + +const toFiniteNumber = (value) => { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +/** 单价:成本价 > 采购价,都拿不到时返回 null */ +const pickUnitPrice = (source = {}) => { + const cost = toFiniteNumber(source.costPrice); + const purchase = toFiniteNumber(source.purchasePrice); + if (cost !== null && cost > 0) return Math.round(cost * 10000) / 10000; + if (purchase !== null && purchase > 0) return Math.round(purchase * 10000) / 10000; + if (cost !== null) return cost; + if (purchase !== null) return purchase; + return null; +}; + +const roundAmount = (value) => (value === null || value === undefined ? null : Math.round(value * 100) / 100); + +/** 总金额:单价 × 总库存;单价缺失时为 null */ +const calcAmount = (unitPrice, qty) => (unitPrice === null ? null : roundAmount(unitPrice * Number(qty || 0))); + +/** + * 只为「导入里有、副本里没有」的编码补单价(副本里的行 SQL 已 JOIN 带出价格)。 + * 一般只有零星几个,返回 Map。 + */ +async function loadPriceMap(codes = []) { + const unique = Array.from(new Set(codes.map((code) => String(code || '').trim()).filter(Boolean))); + const map = new Map(); + if (!unique.length) return map; + + const CHUNK = 500; + for (let start = 0; start < unique.length; start += CHUNK) { + const chunk = unique.slice(start, start + CHUNK); + const params = {}; + const placeholders = chunk.map((code, index) => { + params[`code${index}`] = code; + return `@code${index}`; + }); + // eslint-disable-next-line no-await-in-loop + const rows = await cloudDb.query( + `SELECT + LTRIM(RTRIM(CONVERT(NVARCHAR(120), sku_id))) AS [productCode], + cost_price AS [costPrice], + purchase_price AS [purchasePrice] + FROM dbo.jst_sku + WHERE sku_id IN (${placeholders.join(', ')})`, + params, + { requestTimeout: 120000 } + ); + (rows || []).forEach((row) => { + const code = String(row.productCode || '').trim(); + if (code) map.set(code, { costPrice: row.costPrice, purchasePrice: row.purchasePrice }); + }); + } + return map; +} + +/** 副本行在 SQL 里已 JOIN 出价格,只有「导入里有、副本里没有」的编码才需要额外查一次商品资料 */ +async function loadPriceMapForOverrides(replicaRows = [], overrides = new Map()) { + if (!overrides || overrides.size === 0) return new Map(); + const replicaCodes = new Set((replicaRows || []).map((row) => String(row.productCode || '').trim())); + const missing = Array.from(overrides.keys()).filter((code) => !replicaCodes.has(String(code || '').trim())); + if (!missing.length) return new Map(); + try { + return await loadPriceMap(missing); + } catch (error) { + // 补单价失败不影响主流程,页面按「--」展示 + return new Map(); + } +} + +const normalizeKeyword = (keyword) => { + const value = String(keyword || '').trim(); + return value ? value.slice(0, 100) : ''; +}; + +const matchesKeyword = (row, keyword) => { + if (!keyword) return true; + const needle = keyword.toLowerCase(); + return String(row.productCode || '').toLowerCase().includes(needle) + || String(row.supplier || '').toLowerCase().includes(needle); +}; + +const normalizeSupplier = (value) => { + const text = String(value || '').trim(); + return text ? text.slice(0, 200) : ''; +}; + +/** 供应商筛选:精确匹配(页面下拉里的值来自数据本身),保证页面与导出口径一致 */ +const matchesSupplier = (row, supplier) => (!supplier ? true : String(row.supplier || '').trim() === supplier); + +/** + * 读取导入的覆盖值(以聚水潭导出为准) + * 按仓库隔离:每个看板只吃自己那份聚水潭导出文件,互不覆盖 + */ +async function loadImportOverrides(warehouse = DEFAULT_WAREHOUSE) { + try { + const rows = await database.local.query( + `SELECT product_code, created_at, inbound_qty, supplier + FROM return_stock_warning_import + WHERE warehouse_key = ?`, + [warehouse.key] + ); + const map = new Map(); + (rows || []).forEach((row) => { + const code = String(row.product_code || '').trim(); + if (!code) return; + map.set(code, { + createdAt: row.created_at ? new Date(row.created_at) : null, + inboundQty: row.inbound_qty === null || row.inbound_qty === undefined ? null : Number(row.inbound_qty), + supplier: row.supplier ? String(row.supplier) : '', + }); + }); + return map; + } catch (error) { + // 表尚未建立时静默降级为「无导入」 + return new Map(); + } +} + +async function loadLatestImportBatch(warehouse = DEFAULT_WAREHOUSE) { + try { + const rows = await database.local.query( + `SELECT batch_id, file_name, total_rows, matched_rows, with_created_at, with_qty, + skipped_rows, imported_by, imported_at + FROM return_stock_warning_import_batch + WHERE warehouse_key = ? + ORDER BY imported_at DESC LIMIT 1`, + [warehouse.key] + ); + const row = rows?.[0]; + if (!row) return null; + return { + batchId: row.batch_id, + fileName: row.file_name || '', + totalRows: Number(row.total_rows || 0), + matchedRows: Number(row.matched_rows || 0), + withCreatedAt: Number(row.with_created_at || 0), + withQty: Number(row.with_qty || 0), + skippedRows: Number(row.skipped_rows || 0), + importedBy: row.imported_by || '', + importedAt: formatDateTime(row.imported_at), + }; + } catch (error) { + return null; + } +} + +/** + * 创建时间取值:聚水潭导入 > 选中的副本口径 + */ +function pickCreatedAt(row, override, createdAtBasis) { + if (override && override.createdAt) { + return { raw: override.createdAt, source: 'imported' }; + } + if (createdAtBasis === CREATED_AT_BASIS_FIRST_INBOUND) { + return { raw: row.firstInboundTime || null, source: CREATED_AT_BASIS_FIRST_INBOUND }; + } + const raw = row.stockCreateTime || null; + return { + raw, + source: isBatchStockCreate(raw) ? CREATED_AT_BASIS_STOCK_CREATE_BATCH : CREATED_AT_BASIS_STOCK_CREATE, + }; +} + +/** + * 组装最终行:副本数据 + 导入覆盖 + 口径选择 + */ +function composeRows({ + replicaRows, overrides, qtyBasis, createdAtBasis, today, scope, focusDays, alertDays, priceMap, + priceFocusMin = DEFAULT_PRICE_FOCUS_MIN, priceHighMin = DEFAULT_PRICE_HIGH_MIN, + returnLimitDays = DEFAULT_RETURN_LIMIT_DAYS, warehouse = DEFAULT_WAREHOUSE, +}) { + const hasImport = overrides.size > 0; + const rows = []; + const seen = new Set(); + // 退厂判定:能出现在这份库存数据里,就说明「还没退厂」;哪天编码从库存消失即视为已退厂。 + // 因此这里恒为 false(无需采购退货出库表),两列 outboundDate / outboundQty 也不再对外展示。 + const isOutbound = false; + + replicaRows.forEach((row) => { + const code = String(row.productCode || '').trim(); + if (!code) return; + seen.add(code); + const override = overrides.get(code); + const basisQty = calcQty(row, qtyBasis, warehouse); + const qty = override && override.inboundQty !== null ? override.inboundQty : basisQty; + // 创建时间优先级:聚水潭导入 > 选中的副本口径 + const picked = pickCreatedAt(row, override, createdAtBasis); + const createdAtRaw = picked.raw; + const createdAt = formatDateTime(createdAtRaw); + const stockDays = diffDays(createdAtRaw, today); + const unitPrice = pickUnitPrice(row); + const unitPriceLevel = resolvePriceLevel(unitPrice, priceFocusMin, priceHighMin); + // 重点关注(单价 > priceHighMin)的编码:入库后必须在 returnLimitDays 天内完成退厂 + const deadline = resolveReturnDeadline({ + unitPriceLevel, stockDays, isOutbound, returnLimitDays, + }); + + if (scope === 'imported' && !override) return; + if (qty <= 0) return; + + rows.push({ + productCode: code, + createdAt, + createdAtRaw: createdAtRaw || null, + createdAtSource: picked.source, + createdAtSourceLabel: CREATED_AT_SOURCE_LABELS[picked.source] || '', + inboundQty: qty, + unitPrice, + amount: calcAmount(unitPrice, qty), + unitPriceLevel, + unitPriceLevelLabel: PRICE_WARNING_LABELS[unitPriceLevel] || '', + returnDeadlineStatus: deadline.status, + returnDeadlineStatusLabel: RETURN_DEADLINE_LABELS[deadline.status] || '', + returnRemainDays: deadline.remainDays, + supplier: (override && override.supplier) || row.supplier || '', + returnQty: Number(row.returnQty || 0), + customizeQty1: Number(row.customizeQty1 || 0), + customizeQty2: Number(row.customizeQty2 || 0), + customizeQty3: Number(row.customizeQty3 || 0), + // 本仓「客退临时上架仓」对应位的件数,导出里单列出来便于与聚水潭比对 + returnSlotQty: pickReturnSlotQty(row, warehouse), + outboundDate: '', + outboundQty: null, + stockDays, + warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays), + isOutbound, + source: override ? 'jst' : 'replica', + noInboundRecord: !createdAtRaw, + }); + }); + + // 导入里有、副本里没有的编码:以导入为准补进来 + overrides.forEach((override, code) => { + if (seen.has(code)) return; + if (scope === 'replica') return; + const qty = override.inboundQty === null ? 0 : override.inboundQty; + if (qty <= 0) return; + const stockDays = diffDays(override.createdAt, today); + const source = override.createdAt ? 'imported' : createdAtBasis; + // 副本里没有这行,单价单独查商品资料补上 + const unitPrice = pickUnitPrice(priceMap.get(code) || {}); + const unitPriceLevel = resolvePriceLevel(unitPrice, priceFocusMin, priceHighMin); + const deadline = resolveReturnDeadline({ + unitPriceLevel, stockDays, isOutbound, returnLimitDays, + }); + rows.push({ + productCode: code, + createdAt: formatDateTime(override.createdAt), + createdAtRaw: override.createdAt || null, + createdAtSource: source, + createdAtSourceLabel: CREATED_AT_SOURCE_LABELS[source] || '', + inboundQty: qty, + unitPrice, + amount: calcAmount(unitPrice, qty), + unitPriceLevel, + unitPriceLevelLabel: PRICE_WARNING_LABELS[unitPriceLevel] || '', + returnDeadlineStatus: deadline.status, + returnDeadlineStatusLabel: RETURN_DEADLINE_LABELS[deadline.status] || '', + returnRemainDays: deadline.remainDays, + supplier: override.supplier || '', + returnQty: null, + customizeQty1: null, + customizeQty2: null, + customizeQty3: null, + returnSlotQty: null, + outboundDate: '', + outboundQty: null, + stockDays, + warningLevel: resolveWarningLevel(stockDays, focusDays, alertDays), + isOutbound, + source: 'jst', + noInboundRecord: !override.createdAt, + }); + }); + + return { rows, hasImport }; +} + +function sortRows(rows, sortBy, sortOrder) { + const direction = String(sortOrder).toLowerCase() === 'asc' ? 1 : -1; + const value = (row) => { + if (sortBy === 'createdAt') return row.createdAtRaw ? row.createdAtRaw.getTime() : 0; + if (sortBy === 'productCode') return row.productCode; + if (sortBy === 'supplier') return row.supplier; + if (sortBy === 'stockDays') return row.stockDays === null ? -1 : row.stockDays; + if (sortBy === 'amount') return row.amount === null || row.amount === undefined ? -1 : row.amount; + if (sortBy === 'unitPrice') return row.unitPrice === null || row.unitPrice === undefined ? -1 : row.unitPrice; + if (sortBy === 'unitPriceLevel') return PRICE_LEVEL_RANK[row.unitPriceLevel] || 0; + if (sortBy === 'returnDeadlineStatus') return RETURN_DEADLINE_RANK[row.returnDeadlineStatus] || 0; + // 退厂剩余天数为 null(不适用 / 算不出)时排到最后 + if (sortBy === 'returnRemainDays') { + return row.returnRemainDays === null || row.returnRemainDays === undefined ? -1 : row.returnRemainDays; + } + return row.inboundQty; + }; + return rows.sort((a, b) => { + const va = value(a); + const vb = value(b); + if (typeof va === 'string' || typeof vb === 'string') { + return String(va).localeCompare(String(vb)) * direction; + } + if (va === vb) return String(a.productCode).localeCompare(String(b.productCode)); + return (va - vb) * direction; + }); +} + +function buildSummary(rows) { + const suppliers = new Set(); + let totalQty = 0; + let totalAmount = 0; + let pricedCodes = 0; + let missingPriceCodes = 0; + let maxStockDays = null; + let maxUnitPrice = null; + let noInboundRecord = 0; + let importedCodes = 0; + const warning = { + alertCodes: 0, alertQty: 0, + focusCodes: 0, focusQty: 0, + normalCodes: 0, normalQty: 0, + unknownCodes: 0, unknownQty: 0, + }; + // 单价预警汇总:按价位的编码数 / 件数 / 库存金额(金额最能反映资金占用) + const priceWarning = { + highCodes: 0, highQty: 0, highAmount: 0, + focusCodes: 0, focusQty: 0, focusAmount: 0, + normalCodes: 0, normalQty: 0, normalAmount: 0, + unknownCodes: 0, unknownQty: 0, unknownAmount: 0, + }; + // 退厂时限汇总:只统计「重点关注」(单价 > 重点关注线)的编码——只有这批有退厂时限要求 + const returnDeadline = { + highCodes: 0, highQty: 0, highAmount: 0, + overdueCodes: 0, overdueQty: 0, overdueAmount: 0, + pendingCodes: 0, pendingQty: 0, pendingAmount: 0, + doneCodes: 0, + }; + + rows.forEach((row) => { + const qty = Number(row.inboundQty || 0); + const amount = row.amount === null || row.amount === undefined ? 0 : Number(row.amount || 0); + totalQty += qty; + if (row.supplier) suppliers.add(row.supplier); + // 只有在商品资料里取到单价的编码才计入总金额,其余单列提示,避免金额被低估却看不出来 + if (row.amount === null || row.amount === undefined) { + missingPriceCodes += 1; + } else { + totalAmount += amount; + pricedCodes += 1; + } + if (row.stockDays !== null && (maxStockDays === null || row.stockDays > maxStockDays)) { + maxStockDays = row.stockDays; + } + if (row.unitPrice !== null && row.unitPrice !== undefined + && (maxUnitPrice === null || row.unitPrice > maxUnitPrice)) { + maxUnitPrice = row.unitPrice; + } + if (row.noInboundRecord) noInboundRecord += 1; + if (row.source === 'jst') importedCodes += 1; + + if (row.warningLevel === 'alert') { warning.alertCodes += 1; warning.alertQty += qty; } + else if (row.warningLevel === 'focus') { warning.focusCodes += 1; warning.focusQty += qty; } + else if (row.warningLevel === 'normal') { warning.normalCodes += 1; warning.normalQty += qty; } + else { warning.unknownCodes += 1; warning.unknownQty += qty; } + + if (row.unitPriceLevel === 'high') { priceWarning.highCodes += 1; priceWarning.highQty += qty; priceWarning.highAmount += amount; } + else if (row.unitPriceLevel === 'focus') { priceWarning.focusCodes += 1; priceWarning.focusQty += qty; priceWarning.focusAmount += amount; } + else if (row.unitPriceLevel === 'normal') { priceWarning.normalCodes += 1; priceWarning.normalQty += qty; priceWarning.normalAmount += amount; } + else { priceWarning.unknownCodes += 1; priceWarning.unknownQty += qty; priceWarning.unknownAmount += amount; } + + // 退厂时限:只对重点关注的编码统计「已超期 / 剩余 / 已退厂」 + if (row.unitPriceLevel === 'high') { + returnDeadline.highCodes += 1; + returnDeadline.highQty += qty; + returnDeadline.highAmount += amount; + if (row.returnDeadlineStatus === 'overdue') { + returnDeadline.overdueCodes += 1; + returnDeadline.overdueQty += qty; + returnDeadline.overdueAmount += amount; + } else if (row.returnDeadlineStatus === 'done') { + returnDeadline.doneCodes += 1; + } else { + returnDeadline.pendingCodes += 1; + returnDeadline.pendingQty += qty; + returnDeadline.pendingAmount += amount; + } + } + }); + + priceWarning.highAmount = roundAmount(priceWarning.highAmount); + priceWarning.focusAmount = roundAmount(priceWarning.focusAmount); + priceWarning.normalAmount = roundAmount(priceWarning.normalAmount); + priceWarning.unknownAmount = roundAmount(priceWarning.unknownAmount); + returnDeadline.highAmount = roundAmount(returnDeadline.highAmount); + returnDeadline.overdueAmount = roundAmount(returnDeadline.overdueAmount); + returnDeadline.pendingAmount = roundAmount(returnDeadline.pendingAmount); + + return { + codes: rows.length, + totalQty, + totalAmount: roundAmount(totalAmount), + pricedCodes, + missingPriceCodes, + suppliers: suppliers.size, + maxStockDays, + maxUnitPrice, + noInboundRecord, + importedCodes, + warning, + priceWarning, + returnDeadline, + }; +} + +/** + * 退货库存预警列表 + */ +async function getReturnStockWarningList(options = {}) { + const warehouse = resolveWarehouse(options.warehouse); + const page = Math.max(1, Number.parseInt(options.page, 10) || 1); + const pageSize = Math.min(5000, Math.max(1, Number.parseInt(options.pageSize, 10) || 100)); + const keyword = normalizeKeyword(options.keyword); + const supplier = normalizeSupplier(options.supplier); + const qtyBasis = resolveQtyBasis(options.qtyBasis); + const createdAtBasis = resolveCreatedAtBasis(options.createdAtBasis); + const scope = ['imported', 'replica'].includes(String(options.scope)) ? String(options.scope) : 'all'; + const { focusDays, alertDays } = resolveThresholds(options); + const { priceFocusMin, priceHighMin } = resolvePriceThresholds(options); + const returnLimitDays = resolveReturnLimitDays(options.returnLimitDays); + const warningLevel = resolveWarningLevelFilter(options.warningLevel); + const priceLevel = resolvePriceLevelFilter(options.priceLevel); + const today = formatLocalDate(); + + const [replicaRows, overrides, latestImport] = await Promise.all([ + loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }), + loadImportOverrides(warehouse), + loadLatestImportBatch(warehouse), + ]); + + const priceMap = await loadPriceMapForOverrides(replicaRows, overrides); + + const composed = composeRows({ + replicaRows: replicaRows || [], + overrides, + qtyBasis, + createdAtBasis, + today, + scope, + focusDays, + alertDays, + priceMap, + priceFocusMin, + priceHighMin, + returnLimitDays, + warehouse, + }); + + const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier)); + const sorted = sortRows(filtered, options.sortBy, options.sortOrder); + // 汇总基于「关键字/供应商过滤后、预警等级过滤前」的全集,这样切预警等级时汇总数字不会跟着缩水 + const summary = buildSummary(sorted); + const levelFiltered = sorted.filter((row) => matchesWarningLevel(row.warningLevel, warningLevel)); + const priceFiltered = levelFiltered.filter((row) => matchesPriceLevel(row.unitPriceLevel, priceLevel)); + const start = (page - 1) * pageSize; + + return { + rows: priceFiltered.slice(start, start + pageSize).map((row) => ({ + ...row, + warningLabel: WARNING_LABELS[row.warningLevel] || WARNING_LABELS.normal, + unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '', + createdAtRaw: undefined, + })), + total: priceFiltered.length, + page, + pageSize, + // 当前仓库信息(页面标题 / 口径文案都按它渲染) + warehouseKey: warehouse.key, + warehouseName: warehouse.warehouseName, + warehouseShortName: warehouse.shortName, + warehouseTitle: warehouse.title, + warehouseWmsCoId: warehouse.wmsCoId, + warehouseHasReturnStock: warehouse.hasReturnStock, + returnSlot: warehouse.returnSlot, + warehouse: getWarehouseStockLabel(warehouse), + statDate: today, + stockSyncedAt: stockSyncedAtState.get(warehouse.key)?.value || '', + qtyBasis, + qtyBasisOptions: buildQtyBasis(warehouse), + createdAtBasis, + createdAtBasisOptions: CREATED_AT_BASIS, + unitPriceBasisLabel: UNIT_PRICE_BASIS_LABEL, + scope, + supplier, + focusDays, + alertDays, + priceFocusMin, + priceHighMin, + returnLimitDays, + warningLevel, + warningLevelOptions: WARNING_LEVEL_OPTIONS, + priceLevel, + priceLevelOptions: PRICE_LEVEL_OPTIONS, + summary, + import: latestImport, + hasImport: composed.hasImport, + gaps: buildKnownGaps(warehouse), + }; +} + +/** + * 导出列(按仓库生成)——最后那列「客退临时上架库存」的列名要跟着仓库走, + * 因为各仓用的自定义库存位不同(山东是 customize_qty_2),写死会误导看表的人。 + */ +const buildExportColumns = (warehouse = DEFAULT_WAREHOUSE) => [ + { key: 'warningLabel', title: '预警' }, + { key: 'supplier', title: '供应商' }, + { key: 'productCode', title: '商品编码' }, + { key: 'createdAt', title: '创建时间' }, + { key: 'stockDays', title: '在库天数' }, + { key: 'inboundQty', title: '总库存' }, + { key: 'unitPrice', title: '单价(成本价)' }, + { key: 'amount', title: '总金额' }, + { key: 'unitPriceLevelLabel', title: '单价预警' }, + { key: 'returnDeadlineLabel', title: '退厂时限' }, + { key: 'isOutboundLabel', title: '是否出库' }, + { key: 'returnQty', title: '退货仓库存(return_qty)' }, + { key: 'returnSlotQty', title: `客退临时上架库存(${warehouse.returnSlot})` }, + { key: 'sourceLabel', title: '数据来源' }, +]; + +// 默认仓的导出列(对外导出保持兼容) +const EXPORT_COLUMNS = buildExportColumns(DEFAULT_WAREHOUSE); + +/** + * 导出数据集(不分页,按当前关键字/口径全量) + */ +async function getReturnStockWarningExportRows(options = {}) { + const warehouse = resolveWarehouse(options.warehouse); + const keyword = normalizeKeyword(options.keyword); + const supplier = normalizeSupplier(options.supplier); + const qtyBasis = resolveQtyBasis(options.qtyBasis); + const createdAtBasis = resolveCreatedAtBasis(options.createdAtBasis); + const scope = ['imported', 'replica'].includes(String(options.scope)) ? String(options.scope) : 'all'; + const { focusDays, alertDays } = resolveThresholds(options); + const { priceFocusMin, priceHighMin } = resolvePriceThresholds(options); + const returnLimitDays = resolveReturnLimitDays(options.returnLimitDays); + const warningLevel = resolveWarningLevelFilter(options.warningLevel); + const priceLevel = resolvePriceLevelFilter(options.priceLevel); + const today = formatLocalDate(); + + const [replicaRows, overrides] = await Promise.all([ + loadReplicaRows({ refresh: String(options.refresh) === 'true', createdAtBasis, warehouse }), + loadImportOverrides(warehouse), + ]); + + const priceMap = await loadPriceMapForOverrides(replicaRows, overrides); + + const composed = composeRows({ + replicaRows: replicaRows || [], + overrides, + qtyBasis, + createdAtBasis, + today, + scope, + focusDays, + alertDays, + priceMap, + priceFocusMin, + priceHighMin, + returnLimitDays, + warehouse, + }); + + const filtered = composed.rows.filter((row) => matchesKeyword(row, keyword) && matchesSupplier(row, supplier)); + const levelFiltered = filtered.filter((row) => matchesWarningLevel(row.warningLevel, warningLevel)); + const priceFiltered = levelFiltered.filter((row) => matchesPriceLevel(row.unitPriceLevel, priceLevel)); + // 导出按「预警等级 → 在库天数」排序,红色预警排最前,便于直接拿去处理 + const sorted = sortRows(priceFiltered, 'inboundQty', 'desc'); + + return sorted.map((row) => ({ + ...row, + warningLabel: WARNING_LABELS[row.warningLevel] || WARNING_LABELS.normal, + unitPriceLevelLabel: PRICE_WARNING_LABELS[row.unitPriceLevel] || '', + returnDeadlineLabel: formatReturnDeadlineLabel(row.returnDeadlineStatus, row.returnRemainDays), + isOutboundLabel: row.isOutbound ? '是' : '否', + sourceLabel: row.createdAtSourceLabel || '', + createdAtRaw: undefined, + })); +} + +module.exports = { + WAREHOUSE_LABEL, + KNOWN_GAPS, + QTY_BASIS, + WAREHOUSE_LIST, + resolveWarehouse, + resolveWarehouseKey, + getWarehouseStockLabel, + buildQtyBasis, + buildExportColumns, + buildKnownGaps, + CREATED_AT_BASIS, + WARNING_LEVEL_OPTIONS, + PRICE_LEVEL_OPTIONS, + PRICE_WARNING_LABELS, + DEFAULT_FOCUS_DAYS, + DEFAULT_ALERT_DAYS, + DEFAULT_CREATED_AT_BASIS, + DEFAULT_PRICE_FOCUS_MIN, + DEFAULT_PRICE_HIGH_MIN, + DEFAULT_RETURN_LIMIT_DAYS, + RETURN_DEADLINE_LABELS, + UNIT_PRICE_BASIS_LABEL, + EXPORT_COLUMNS, + warmReturnStockWarningCache, + loadStockSyncedAt, + getReturnStockWarningList, + getReturnStockWarningExportRows, +}; diff --git a/frontend/src/api/request.js b/frontend/src/api/request.js index 3799795..95d7dea 100644 --- a/frontend/src/api/request.js +++ b/frontend/src/api/request.js @@ -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)) { diff --git a/frontend/src/api/returnMonitor.js b/frontend/src/api/returnMonitor.js new file mode 100644 index 0000000..43b4113 --- /dev/null +++ b/frontend/src/api/returnMonitor.js @@ -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, + } +); diff --git a/frontend/src/api/returnStockWarning.js b/frontend/src/api/returnStockWarning.js new file mode 100644 index 0000000..86eff31 --- /dev/null +++ b/frontend/src/api/returnStockWarning.js @@ -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 } : {} } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 63a6520..d74aba2 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -18,6 +18,16 @@ export const DEFAULT_SIDEBAR_CONFIG = { { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, ], }, + { + key: 'returns-inventory', type: 'group', label: '退货库存监控', icon: 'inbox', visible: true, order: 75, defaultExpanded: true, + children: [ + { key: 'returns-inventory-henlin2', label: '横林二仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-henlin2', visible: true, order: 10 }, + { key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 }, + { key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 }, + { key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 }, + { key: 'returns-inventory-henan', label: '河南客退数据监控', icon: 'table', routeKey: 'returns-inventory-henan', visible: true, order: 50 }, + ], + }, { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, { key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index 9347efe..aa991d6 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -8,6 +8,7 @@ import { DownOutlined, ExperimentOutlined, FundProjectionScreenOutlined, + InboxOutlined, LineChartOutlined, LogoutOutlined, MenuFoldOutlined, @@ -60,6 +61,7 @@ const SIDEBAR_ICON_MAP = { dashboard: DashboardOutlined, desktop: DesktopOutlined, experiment: ExperimentOutlined, + inbox: InboxOutlined, 'line-chart': LineChartOutlined, message: MessageOutlined, projection: FundProjectionScreenOutlined, @@ -79,6 +81,31 @@ const SIDEBAR_ROUTE_MAP = { }, 'promotion-module': { path: '/planning/operation/promotion-module' }, 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'returns-inventory-henlin2': { + path: '/planning/operation/returns-inventory/henlin2', + activePath: '/planning/operation/returns-inventory/henlin2', + matchMode: 'prefix', + }, + 'returns-inventory-subei': { + path: '/planning/operation/returns-inventory/subei', + activePath: '/planning/operation/returns-inventory/subei', + matchMode: 'prefix', + }, + 'returns-inventory-suining': { + path: '/planning/operation/returns-inventory/suining', + activePath: '/planning/operation/returns-inventory/suining', + matchMode: 'prefix', + }, + 'returns-inventory-shandong': { + path: '/planning/operation/returns-inventory/shandong', + activePath: '/planning/operation/returns-inventory/shandong', + matchMode: 'prefix', + }, + 'returns-inventory-henan': { + path: '/planning/operation/returns-inventory/henan', + activePath: '/planning/operation/returns-inventory/henan', + matchMode: 'prefix', + }, 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, 'product-analysis': { path: '/planning/operation/product-analysis' }, 'work-orders': { path: '/planning/operation/work-orders' }, @@ -101,7 +128,6 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf if (entry.requiresAdmin && !canUseSettings) return []; if (entry.type === 'section') return [{ ...entry }]; - const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; const routeMatches = (itemRoute = {}, pathname = '') => { const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { @@ -119,9 +145,16 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) : pathname === matchPath; }; + + // 递归构建菜单节点:支持任意层级(分组 -> 子分组 -> 叶子) const buildItem = (item) => { + if (!item) return null; if (item.requiresAdmin && !canUseSettings) return null; const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; + const rawChildren = Array.isArray(item.children) ? item.children : null; + const children = rawChildren + ? sortSidebarEntries(rawChildren).map((child) => buildItem(child)).filter(Boolean) + : undefined; return { ...item, icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, @@ -129,25 +162,88 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf external: Boolean(itemRoute.externalUrl), onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, match: (path) => routeMatches(itemRoute, path), + ...(children && children.length ? { children } : (rawChildren ? { children: [] } : {})), }; }; - const normalized = { - ...entry, - icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, - path: route.path, - external: Boolean(route.externalUrl), - onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, - match: (path) => routeMatches(route, path), - }; - - if (entry.type === 'group') { - normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); - } - + const normalized = buildItem(entry); + if (!normalized) return []; + normalized.type = entry.type; return [normalized]; }); +// 递归收集默认展开的菜单 key(支持多级) +const collectDefaultExpandedKeys = (entries = []) => entries.flatMap((entry) => [ + ...(entry?.defaultExpanded && entry?.key ? [entry.key] : []), + ...collectDefaultExpandedKeys(entry?.children || []), +]); + +// 递归判断菜单项(含其子孙)是否命中当前路径 +const isNavEntryActive = (entry, pathname) => { + if (!entry) return false; + if (entry.match?.(pathname)) return true; + return Array.isArray(entry.children) && entry.children.some((child) => isNavEntryActive(child, pathname)); +}; + +// 侧边栏菜单节点(递归渲染,支持三级及以上) +const SidebarNavNode = ({ + item, level, pathname, expandedKeys, onToggle, onLeaf, collapsed, +}) => { + const Icon = item.icon || AppstoreOutlined; + const expandable = Array.isArray(item.children) && item.children.length > 0; + const descendantActive = expandable && item.children.some((child) => isNavEntryActive(child, pathname)); + const selfActive = Boolean(item.match?.(pathname)); + const active = selfActive || (collapsed && descendantActive); + const expanded = expandable && expandedKeys.includes(item.key) && !collapsed; + const isTop = level <= 1; + const baseClass = isTop ? 'planning-subsystem-nav-item' : 'planning-subsystem-subnav-item'; + const iconClass = isTop ? 'planning-subsystem-nav-icon' : 'planning-subsystem-subnav-icon'; + + return ( +
+ + + {expandable && expanded ? ( +
+ {item.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +}; + function parseJwtPayload(token) { if (!token) { return null; @@ -292,9 +388,7 @@ const BasicLayout = () => { setSidebarConfig(nextSidebarConfig); setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); - setExpandedSidebarKeys( - nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); + setExpandedSidebarKeys(collectDefaultExpandedKeys(nextSidebarConfig.entries)); document.title = nextSiteName; let link = document.querySelector("link[rel*='icon']"); @@ -504,12 +598,6 @@ const BasicLayout = () => { }, ].filter(Boolean); - const isSidebarEntryActive = (entry) => { - if (!entry) return false; - if (entry.match?.(pathname)) return true; - return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); - }; - const toggleSidebarGroup = (key) => { setSidebarCollapsed(false); setExpandedSidebarKeys((keys) => ( @@ -567,58 +655,17 @@ const BasicLayout = () => { ); } - const Icon = item.icon; - const expandable = Array.isArray(item.children) && item.children.length > 0; - const descendantActive = expandable && item.children.some((child) => isSidebarEntryActive(child)); - const selfActive = Boolean(item.match?.(pathname)); - const active = selfActive || (sidebarCollapsed && descendantActive); - const expanded = expandable && expandedSidebarKeys.includes(item.key) && !sidebarCollapsed; - return ( -
- - - {expandable && expanded ? ( -
- {item.children.map((child) => { - const ChildIcon = child.icon; - const childActive = isSidebarEntryActive(child); - return ( - - ); - })} -
- ) : null} -
+ ); })} @@ -976,6 +1023,22 @@ const BasicLayout = () => { background: var(--sidebar-active-bg); } + .planning-subsystem-subnav-item.is-descendant-active:not(.is-active) { + color: var(--sidebar-active-text); + } + + .planning-subsystem-subnav-label { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .planning-subsystem-subnav-item.is-expandable .planning-subsystem-nav-chevron { + margin-left: 0; + } + .planning-subsystem-subnav-icon { flex: 0 0 14px; color: var(--sidebar-icon-muted); diff --git a/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx b/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx new file mode 100644 index 0000000..1d21a45 --- /dev/null +++ b/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx @@ -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 ( + +
+
+

{warehouse.title}

+

+ 数据取自聚水潭「箱及仓位库存 · {warehouse.warehouseName} · {warehouse.returnBinName}」: + 在库天数 = 今天 − 创建时间,超过预警天数标红。 + 创建时间、件数口径与退厂判定说明见页面底部「数据来源与口径说明」。 +

+ {warehouse.hasReturnStock && warehouse.returnSlot !== 'customize_qty_1' ? ( +

+ 注:该仓位在业务库里占用的是 {warehouse.returnSlot} + (横林二仓 / 苏北 / 睢宁用的是 customize_qty_1),页面已按这个位取数。 +

+ ) : null} + {!warehouse.hasReturnStock ? ( +

+ 提示:该仓位当前在业务库副本里没有客退在库数据(三个自定义库存位全为 0),表格会是空的; + 等聚水潭侧有客退库存同步进来后会自动出现内容,也可以用「导入」上传聚水潭导出的文件。 +

+ ) : null} +
+
+ + +
+ ); +}; + +export default Monitor; diff --git a/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx b/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx new file mode 100644 index 0000000..279e508 --- /dev/null +++ b/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx @@ -0,0 +1,1067 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Alert, Button, Dropdown, Empty, Input, InputNumber, Popconfirm, Select, Spin, Table, Tag, Tooltip, Upload, + message, +} from 'antd'; +import { + ClockCircleOutlined, CloseOutlined, DeleteOutlined, DownOutlined, DownloadOutlined, + ExclamationCircleFilled, QuestionCircleOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, + UpOutlined, UploadOutlined, +} from '@ant-design/icons'; +import { + clearReturnStockWarningImport, exportReturnStockWarning, getReturnStockWarningList, + importReturnStockWarning, +} from '../../api/returnStockWarning'; +import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport'; +import StockWarningDashboard, { MetricCard } from './StockWarningDashboard'; +import { resolveWarehouseConfig } from './warehouses'; + +const num = (value) => (value === null || value === undefined || value === '' ? '--' : Number(value).toLocaleString('zh-CN')); +const txt = (value) => (value === null || value === undefined || value === '' ? '--' : value); +// 金额:两位小数 + 千分位;取不到值时返回 null,由调用方决定怎么展示 +const money = (value) => (value === null || value === undefined || value === '' + ? null + : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })); + +// 单价口径(与后端 returnStockWarningService.UNIT_PRICE_BASIS_LABEL 保持一致) +const UNIT_PRICE_BASIS_LABEL = '成本价 cost_price(缺失时回退采购价 purchase_price)'; + +// 数据总量在千级,一次取整页后在前端排序/分页,避免每次点表头都回库 +const PAGE_SIZE = 2000; + +// 库龄预警阈值默认值(与后端 returnStockWarningService 保持一致) +// 分级:在库天数 > 预警天数 → 红色预警(默认 > 15 天);> 关注天数 → 关注(默认 > 7 天);其余正常 +const DEFAULT_FOCUS_DAYS = 7; +const DEFAULT_ALERT_DAYS = 15; +const MAX_THRESHOLD_DAYS = 3650; + +// 单价预警阈值默认值(与后端 returnStockWarningService 保持一致) +// 分级:单价 > 重点关注线(默认 300 元)→ 重点关注;≥ 关注线(默认 200 元)→ 关注;其余正常 +const DEFAULT_PRICE_FOCUS_MIN = 200; +const DEFAULT_PRICE_HIGH_MIN = 300; +const MAX_PRICE_THRESHOLD = 1000000; + +// 重点关注(单价 > 重点关注线)商品的退厂时限(与后端 returnStockWarningService 保持一致) +// 要求:入库后 7 天内完成库存退厂;在库天数 > 7 天即为「已超期」 +const DEFAULT_RETURN_LIMIT_DAYS = 7; +const MAX_RETURN_LIMIT_DAYS = 3650; + +const LEVEL_TAGS = { + alert: { color: 'red', label: '红色预警', tone: 'red' }, + focus: { color: 'volcano', label: '关注', tone: 'orange' }, + normal: { color: 'green', label: '正常', tone: 'green' }, + unknown: { color: 'default', label: '未知', tone: 'grey' }, +}; + +// 单价预警标签 / 排序权重(与后端 PRICE_WARNING_LABELS / PRICE_LEVEL_RANK 保持一致) +const PRICE_LEVEL_TAGS = { + high: { color: 'red', label: '重点关注' }, + focus: { color: 'volcano', label: '关注' }, + normal: { color: 'green', label: '正常' }, + unknown: { color: 'default', label: '无单价' }, +}; +const PRICE_LEVEL_RANK = { high: 3, focus: 2, normal: 1, unknown: 0 }; + +// 退厂时限状态标签 / 排序权重(与后端 RETURN_DEADLINE_LABELS 保持一致) +const RETURN_DEADLINE_TAGS = { + overdue: { color: 'red', label: '已超期' }, + pending: { color: 'orange', label: '剩余' }, + done: { color: 'green', label: '已退厂' }, + none: { color: 'default', label: '—' }, +}; +const RETURN_DEADLINE_RANK = { overdue: 3, pending: 2, done: 1, none: 0 }; + +// 预警等级过滤:主视图改成一排状态页签(全部 / 红色预警 / 关注 / 正常 / 未知), +// 因此不再需要下拉选项;导出时仍把 warningLevel 原样传给后端(服务端 filter 同口径)。 + +// 件数口径:与后端 returnStockWarningService.QTY_BASIS 保持一致, +// 用于和聚水潭页面比对时切换,找出到底哪个口径与聚水潭一致。 +// 2026-09-17 用户指定「每个看板取聚水潭对应客退上架仓中的数据」,所以默认 = 仅该仓位 +// (c1),不再叠加退货仓 return_qty(销退仓是另一个仓位)。 +const buildQtyBasisOptions = (config) => { + const slot = config.returnSlot; + const bin = config.returnBinName; + return [ + { value: 'c1', label: `仅${bin}`, note: `默认。聚水潭「${bin}」这个仓位的库存,对应副本列 ${slot}` }, + { value: 'rt_c1', label: `${bin} + 退货仓`, note: `${slot} + return_qty(若聚水潭把销退仓的货也算进该仓位)` }, + { value: 'rt', label: '仅退货仓', note: 'return_qty(聚水潭「销售退仓 / 销退仓」口径)' }, + { value: 'all', label: '退货 + 客退上架 + 在途', note: '再叠加 in_qty,一般偏大' }, + ]; +}; + +const SCOPE_OPTIONS = [ + { value: 'all', label: '全部编码' }, + { value: 'imported', label: '仅聚水潭导入的编码' }, + { value: 'replica', label: '仅副本有库存的编码' }, +]; + +// 创建时间口径:与后端 returnStockWarningService.CREATED_AT_BASIS 保持一致 +// 用来和聚水潭「箱及仓位库存 - 创建日期」逐条比对,切换即可看出哪个口径对得上 +const CREATED_AT_BASIS_OPTIONS = [ + { + value: 'stockCreate', + label: '聚水潭库存记录创建时间', + note: 'jst_inventoryInfo.createTime:该编码在当前这条库存记录上的建仓时间;' + + '约 27% 的老库存取到的是 2025-12-03 期初批量建账时间,不是真实上架时间(默认)', + }, + { + value: 'firstInbound', + label: '最早入库时间(旧口径)', + note: 'ERP 售后单 ∪ 奇门退款单取 MIN,通常比聚水潭早很多', + }, +]; + +const clampThreshold = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(MAX_THRESHOLD_DAYS, parsed); +}; + +/** 阈值归一:关注线不能高于预警线 */ +const normalizeThresholds = (focusDays, alertDays) => { + const focus = clampThreshold(focusDays, DEFAULT_FOCUS_DAYS); + const alert = clampThreshold(alertDays, DEFAULT_ALERT_DAYS); + return { focusDays: Math.min(focus, alert), alertDays: Math.max(focus, alert) }; +}; + +const levelOf = (stockDays, focusDays, alertDays) => { + if (stockDays === null || stockDays === undefined) return 'unknown'; + if (stockDays > alertDays) return 'alert'; + if (stockDays > focusDays) return 'focus'; + return 'normal'; +}; + +const clampPrice = (value, fallback) => { + const parsed = Number.parseFloat(value); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(MAX_PRICE_THRESHOLD, Math.round(parsed * 100) / 100); +}; + +/** 单价阈值归一:关注线不能高于重点关注线 */ +const normalizePriceThresholds = (focusMin, highMin) => { + const focus = clampPrice(focusMin, DEFAULT_PRICE_FOCUS_MIN); + const high = clampPrice(highMin, DEFAULT_PRICE_HIGH_MIN); + return { priceFocusMin: Math.min(focus, high), priceHighMin: Math.max(focus, high) }; +}; + +/** 单价预警分级:「大于」判定,与页面文案一致 */ +const priceLevelOf = (unitPrice, priceFocusMin, priceHighMin) => { + if (unitPrice === null || unitPrice === undefined || unitPrice === '') return 'unknown'; + const price = Number(unitPrice); + if (!Number.isFinite(price)) return 'unknown'; + if (price > priceHighMin) return 'high'; + if (price >= priceFocusMin) return 'focus'; + return 'normal'; +}; + +const clampReturnLimit = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(MAX_RETURN_LIMIT_DAYS, parsed); +}; + +/** + * 退厂时限判定(只对「重点关注」编码生效,与后端 resolveReturnDeadline 同口径): + * 非重点关注 → none(不适用) + * 已出库 → done(已退厂,保留分支:退厂完成即从库存消失,一般不会出现在报表里) + * 在库天数 > 退厂时限 → overdue,remainDays = 超期天数 + * 其余 → pending,remainDays = 剩余天数 + * 拿不到创建时间(算不出在库天数)时按 pending 处理,剩余天数留空。 + */ +const returnDeadlineOf = (row, unitPriceLevel, limitDays) => { + if (unitPriceLevel !== 'high') return { status: 'none', remainDays: null }; + if (row.isOutbound) return { status: 'done', remainDays: null }; + const days = row.stockDays; + if (days === null || days === undefined) return { status: 'pending', remainDays: null }; + if (days > limitDays) return { status: 'overdue', remainDays: days - limitDays }; + return { status: 'pending', remainDays: limitDays - days }; +}; + +/** 退厂时限一句话文案(与后端 formatReturnDeadlineLabel 一致) */ +const returnDeadlineText = (status, remainDays) => { + if (status === 'overdue') return remainDays === null ? '已超期' : `已超期 ${remainDays} 天`; + if (status === 'pending') return remainDays === null ? '剩余天数未知' : `剩余 ${remainDays} 天`; + if (status === 'done') return '已退厂'; + return '—'; +}; + +const matchesPriceLevel = (level, filter) => { + if (!filter || filter === 'all') return true; + if (filter === 'high') return level === 'high'; + if (filter === 'focus') return level === 'focus'; + if (filter === 'normal') return level === 'normal'; + if (filter === 'unknown') return level === 'unknown'; + return true; +}; + +const LEVEL_RANK = { alert: 3, focus: 2, normal: 1, unknown: 0 }; + +const matchesLevel = (level, filter) => { + if (!filter || filter === 'all') return true; + if (filter === 'alert') return level === 'alert'; + if (filter === 'focus') return level === 'focus'; + if (filter === 'attention') return level === 'alert' || level === 'focus'; + if (filter === 'normal') return level === 'normal'; + if (filter === 'unknown') return level === 'unknown'; + return true; +}; + +const COLUMNS = [ + { + title: '预警', + dataIndex: 'warningLevel', + width: 104, + fixed: 'left', + align: 'center', + sorter: (a, b) => (LEVEL_RANK[a.warningLevel] || 0) - (LEVEL_RANK[b.warningLevel] || 0), + defaultSortOrder: 'descend', + render: (value) => { + const tag = LEVEL_TAGS[value] || LEVEL_TAGS.normal; + return {tag.label}; + }, + }, + { title: '供应商', dataIndex: 'supplier', width: 150, fixed: 'left', ellipsis: true, render: txt }, + { title: '商品编码', dataIndex: 'productCode', width: 190, fixed: 'left', render: txt }, + { + title: '创建时间', + dataIndex: 'createdAt', + width: 170, + sorter: (a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')), + render: (value, record) => (record.noInboundRecord + ? {txt(value)} + : {txt(value)}), + }, + { + title: '在库天数', + dataIndex: 'stockDays', + width: 110, + align: 'center', + sorter: (a, b) => Number(a.stockDays || 0) - Number(b.stockDays || 0), + // 颜色跟随预警等级,避免「天数」和「预警」两列配色打架 + render: (value, record) => { + if (value === null || value === undefined) return --; + const tag = LEVEL_TAGS[record.warningLevel] || LEVEL_TAGS.normal; + return {value} 天; + }, + }, + { + title: '总库存', + dataIndex: 'inboundQty', + width: 110, + align: 'right', + sorter: (a, b) => Number(a.inboundQty || 0) - Number(b.inboundQty || 0), + render: num, + }, + { + title: '单价', + dataIndex: 'unitPrice', + width: 100, + align: 'right', + sorter: (a, b) => Number(a.unitPrice || 0) - Number(b.unitPrice || 0), + render: (value) => { + const text = money(value); + if (text === null) { + return ( + + -- + + ); + } + return ( + + ¥{text} + + ); + }, + }, + { + title: '总金额', + dataIndex: 'amount', + width: 130, + align: 'right', + sorter: (a, b) => Number(a.amount || 0) - Number(b.amount || 0), + render: (value, record) => { + const text = money(value); + if (text === null) { + return ( + + -- + + ); + } + return ( + + ¥{text} + + ); + }, + }, + { + title: '单价预警', + dataIndex: 'unitPriceLevel', + width: 110, + align: 'center', + sorter: (a, b) => (PRICE_LEVEL_RANK[a.unitPriceLevel] || 0) - (PRICE_LEVEL_RANK[b.unitPriceLevel] || 0), + render: (value) => { + const tag = PRICE_LEVEL_TAGS[value] || PRICE_LEVEL_TAGS.normal; + const tip = value === 'high' + ? '单价高于「重点关注」线,单件占用的资金最多' + : value === 'focus' + ? '单价落在「关注」区间' + : value === 'unknown' + ? '商品资料(jst_sku)里没有成本价和采购价,无法分级' + : '单价低于「关注」线'; + return {tag.label}; + }, + }, + { + title: '退厂时限', + dataIndex: 'returnDeadlineStatus', + width: 128, + align: 'center', + sorter: (a, b) => (RETURN_DEADLINE_RANK[a.returnDeadlineStatus] || 0) + - (RETURN_DEADLINE_RANK[b.returnDeadlineStatus] || 0), + render: (value, record) => { + // 只有「重点关注」(单价 > 重点关注线)的编码才有退厂时限要求 + if (!value || value === 'none') { + return ( + + — + + ); + } + const tag = RETURN_DEADLINE_TAGS[value] || RETURN_DEADLINE_TAGS.none; + const text = returnDeadlineText(value, record.returnRemainDays); + if (value === 'done') { + return {text}; + } + const tip = value === 'overdue' + ? `重点关注编码要求入库后按时限完成库存退厂,已超期 ${record.returnRemainDays} 天,请尽快催办` + : record.returnRemainDays === null + ? '算不出在库天数(缺创建时间),暂时给不出剩余天数' + : `距退厂时限还剩 ${record.returnRemainDays} 天`; + return ( + + {text} + + ); + }, + }, + { + title: '是否出库', + dataIndex: 'isOutbound', + width: 90, + align: 'center', + render: (value) => (value ? 是 : 否), + }, + { + title: '创建时间来源', + dataIndex: 'createdAtSourceLabel', + width: 150, + align: 'center', + render: (value, record) => { + if (record.createdAtSource === 'imported') { + return {value || '聚水潭导入'}; + } + if (record.createdAtSource === 'firstInbound') { + return {value || 'ERP最早入库'}; + } + if (record.createdAtSource === 'stockCreateBatch') { + return ( + + 期初建账(时间不准) + + ); + } + return {value || '聚水潭库存记录'}; + }, + }, +]; + +/** + * 客退数据监控主表(按仓库出数) + * warehouse: 仓库标识 henlin2 / subei / suining / shandong / henan, + * 透传给列表 / 导出 / 导入 / 清除导入四个接口;缺省走后端默认仓(横林二仓) + */ +const StockWarning = ({ warehouse }) => { + // 前端只用来定文案(导出文件名 / 空态提示),取数与口径一律以后端返回为准 + const warehouseConfig = useMemo(() => resolveWarehouseConfig(warehouse), [warehouse]); + const warehouseLabel = warehouseConfig.shortName; + const qtyBasisOptions = useMemo(() => buildQtyBasisOptions(warehouseConfig), [warehouseConfig]); + const [keyword, setKeyword] = useState(''); + const [supplier, setSupplier] = useState(undefined); + const [qtyBasis, setQtyBasis] = useState('c1'); + const [createdAtBasis, setCreatedAtBasis] = useState('stockCreate'); + const [scope, setScope] = useState('all'); + const [focusDays, setFocusDays] = useState(DEFAULT_FOCUS_DAYS); + const [alertDays, setAlertDays] = useState(DEFAULT_ALERT_DAYS); + const [warningLevel, setWarningLevel] = useState('all'); + const [priceFocusMin, setPriceFocusMin] = useState(DEFAULT_PRICE_FOCUS_MIN); + const [priceHighMin, setPriceHighMin] = useState(DEFAULT_PRICE_HIGH_MIN); + const [priceLevel, setPriceLevel] = useState('all'); + // 重点关注(单价 > 重点关注线)商品的退厂时限:入库后须在这么多天内完成库存退厂 + const [returnLimitDays, setReturnLimitDays] = useState(DEFAULT_RETURN_LIMIT_DAYS); + const [data, setData] = useState({ + rows: [], total: 0, summary: {}, warehouse: '', warehouseName: '', returnSlot: '', + warehouseHasReturnStock: true, statDate: '', stockSyncedAt: '', gaps: [], import: null, + hasImport: false, + }); + const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(''); + // 主视图只留最常用的筛选,其余收进「更多筛选」;图表与口径说明默认折叠, + // 首次进来看到的就只是「状态页签 + 筛选 + 指标卡 + 明细表」这一屏 + const [showAdvanced, setShowAdvanced] = useState(false); + const [showCharts, setShowCharts] = useState(false); + const [showNotes, setShowNotes] = useState(false); + + const load = useCallback(async ({ refresh = false } = {}) => { + setLoading(true); + setError(''); + try { + const result = await getReturnStockWarningList({ + warehouse, + page: 1, + pageSize: PAGE_SIZE, + qtyBasis, + createdAtBasis, + scope, + refresh: refresh ? 'true' : undefined, + }); + const payload = result?.data || {}; + setData({ + rows: payload.rows || [], + total: payload.total || 0, + summary: payload.summary || {}, + warehouse: payload.warehouse || '', + warehouseName: payload.warehouseName || '', + returnSlot: payload.returnSlot || '', + warehouseHasReturnStock: payload.warehouseHasReturnStock !== false, + statDate: payload.statDate || '', + stockSyncedAt: payload.stockSyncedAt || '', + gaps: payload.gaps || [], + import: payload.import || null, + hasImport: Boolean(payload.hasImport), + }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载客退数据监控失败'); + } finally { + setLoading(false); + } + }, [warehouse, qtyBasis, createdAtBasis, scope]); + + useEffect(() => { load(); }, [load]); + + const handleImport = useCallback(async (file) => { + setImporting(true); + try { + const result = await importReturnStockWarning(file, warehouse); + const payload = result?.data || {}; + message.success( + `导入成功:识别 ${payload.matchedRows || 0} 个商品编码` + + `(含创建时间 ${payload.withCreatedAt || 0} 个、含件数 ${payload.withQty || 0} 个)` + ); + await load({ refresh: true }); + } catch (importError) { + message.error(importError?.response?.data?.error || importError?.message || '导入失败'); + } finally { + setImporting(false); + } + return false; + }, [load, warehouse]); + + const handleClearImport = useCallback(async () => { + try { + await clearReturnStockWarningImport(warehouse); + message.success('已清除本仓导入数据,恢复为系统推算口径'); + await load({ refresh: true }); + } catch (clearError) { + message.error(clearError?.response?.data?.error || clearError?.message || '清除失败'); + } + }, [load, warehouse]); + + // 阈值只在前端算,改数值即时生效、不用回库;导出时把同一组阈值传给后端,两边结果一致 + const thresholds = useMemo(() => normalizeThresholds(focusDays, alertDays), [focusDays, alertDays]); + const priceThresholds = useMemo( + () => normalizePriceThresholds(priceFocusMin, priceHighMin), + [priceFocusMin, priceHighMin] + ); + + const rowsWithLevel = useMemo(() => (data.rows || []).map((row) => { + const unitPriceLevel = priceLevelOf(row.unitPrice, priceThresholds.priceFocusMin, priceThresholds.priceHighMin); + const deadline = returnDeadlineOf(row, unitPriceLevel, returnLimitDays); + return { + ...row, + warningLevel: levelOf(row.stockDays, thresholds.focusDays, thresholds.alertDays), + unitPriceLevel, + returnDeadlineStatus: deadline.status, + returnRemainDays: deadline.remainDays, + }; + }), [data.rows, thresholds, priceThresholds, returnLimitDays]); + + // 供应商下拉的候选项来自当前已加载的数据(全量在内存里),所以筛选是即时的、不用回库 + const supplierOptions = useMemo(() => { + const names = new Set(); + (data.rows || []).forEach((row) => { + const name = String(row.supplier || '').trim(); + if (name) names.add(name); + }); + return Array.from(names) + .sort((a, b) => a.localeCompare(b, 'zh-CN')) + .map((name) => ({ value: name, label: name })); + }, [data.rows]); + + const keywordRows = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + let rows = rowsWithLevel; + if (supplier) rows = rows.filter((row) => String(row.supplier || '').trim() === supplier); + if (!kw) return rows; + return rows.filter((row) => ( + String(row.productCode || '').toLowerCase().includes(kw) + || String(row.supplier || '').toLowerCase().includes(kw) + )); + }, [keyword, supplier, rowsWithLevel]); + + // 汇总基于「关键字/供应商过滤后、预警等级过滤前」的全集,这样点预警汇总条时数字不会跟着缩水 + const summary = useMemo(() => { + const suppliers = new Set(); + let totalQty = 0; + let totalAmount = 0; + let pricedCodes = 0; + let missingPriceCodes = 0; + let maxStockDays = null; + let maxUnitPrice = null; + let importedCodes = 0; + const warning = { + alertCodes: 0, alertQty: 0, + focusCodes: 0, focusQty: 0, + normalCodes: 0, normalQty: 0, + unknownCodes: 0, unknownQty: 0, + }; + // 单价预警汇总:按价位的编码数 / 件数 / 库存金额(金额最能反映资金占用) + const priceWarning = { + highCodes: 0, highQty: 0, highAmount: 0, + focusCodes: 0, focusQty: 0, focusAmount: 0, + normalCodes: 0, normalQty: 0, normalAmount: 0, + unknownCodes: 0, unknownQty: 0, unknownAmount: 0, + }; + // 退厂时限汇总:只统计「重点关注」(单价 > 重点关注线)的编码——只有这批有退厂时限要求 + const returnDeadline = { + highCodes: 0, highQty: 0, highAmount: 0, + overdueCodes: 0, overdueQty: 0, overdueAmount: 0, + pendingCodes: 0, pendingQty: 0, pendingAmount: 0, + doneCodes: 0, + }; + keywordRows.forEach((row) => { + const qty = Number(row.inboundQty || 0); + const amount = row.amount === null || row.amount === undefined ? 0 : Number(row.amount || 0); + totalQty += qty; + if (row.amount === null || row.amount === undefined) { + missingPriceCodes += 1; + } else { + totalAmount += amount; + pricedCodes += 1; + } + if (row.supplier) suppliers.add(row.supplier); + if (row.stockDays !== null && row.stockDays !== undefined + && (maxStockDays === null || row.stockDays > maxStockDays)) { + maxStockDays = row.stockDays; + } + if (row.unitPrice !== null && row.unitPrice !== undefined + && (maxUnitPrice === null || Number(row.unitPrice) > maxUnitPrice)) { + maxUnitPrice = Number(row.unitPrice); + } + if (row.source === 'jst') importedCodes += 1; + if (row.warningLevel === 'alert') { warning.alertCodes += 1; warning.alertQty += qty; } + else if (row.warningLevel === 'focus') { warning.focusCodes += 1; warning.focusQty += qty; } + else if (row.warningLevel === 'normal') { warning.normalCodes += 1; warning.normalQty += qty; } + else { warning.unknownCodes += 1; warning.unknownQty += qty; } + + if (row.unitPriceLevel === 'high') { priceWarning.highCodes += 1; priceWarning.highQty += qty; priceWarning.highAmount += amount; } + else if (row.unitPriceLevel === 'focus') { priceWarning.focusCodes += 1; priceWarning.focusQty += qty; priceWarning.focusAmount += amount; } + else if (row.unitPriceLevel === 'normal') { priceWarning.normalCodes += 1; priceWarning.normalQty += qty; priceWarning.normalAmount += amount; } + else { priceWarning.unknownCodes += 1; priceWarning.unknownQty += qty; priceWarning.unknownAmount += amount; } + + // 退厂时限:只对重点关注的编码统计「已超期 / 剩余 / 已退厂」 + if (row.unitPriceLevel === 'high') { + returnDeadline.highCodes += 1; + returnDeadline.highQty += qty; + returnDeadline.highAmount += amount; + if (row.returnDeadlineStatus === 'overdue') { + returnDeadline.overdueCodes += 1; + returnDeadline.overdueQty += qty; + returnDeadline.overdueAmount += amount; + } else if (row.returnDeadlineStatus === 'done') { + returnDeadline.doneCodes += 1; + } else { + returnDeadline.pendingCodes += 1; + returnDeadline.pendingQty += qty; + returnDeadline.pendingAmount += amount; + } + } + }); + return { + codes: keywordRows.length, + totalQty, + totalAmount: Math.round(totalAmount * 100) / 100, + pricedCodes, + missingPriceCodes, + suppliers: suppliers.size, + maxStockDays, + maxUnitPrice, + importedCodes, + warning, + priceWarning: { + ...priceWarning, + highAmount: Math.round(priceWarning.highAmount * 100) / 100, + focusAmount: Math.round(priceWarning.focusAmount * 100) / 100, + normalAmount: Math.round(priceWarning.normalAmount * 100) / 100, + unknownAmount: Math.round(priceWarning.unknownAmount * 100) / 100, + }, + returnDeadline: { + ...returnDeadline, + highAmount: Math.round(returnDeadline.highAmount * 100) / 100, + overdueAmount: Math.round(returnDeadline.overdueAmount * 100) / 100, + pendingAmount: Math.round(returnDeadline.pendingAmount * 100) / 100, + }, + }; + }, [keywordRows]); + + const filteredRows = useMemo( + () => keywordRows.filter( + (row) => matchesLevel(row.warningLevel, warningLevel) && matchesPriceLevel(row.unitPriceLevel, priceLevel) + ), + [keywordRows, warningLevel, priceLevel] + ); + + const handleExport = useCallback(async (format) => { + setExporting(true); + try { + const blob = await exportReturnStockWarning({ + warehouse, + keyword: keyword.trim(), + supplier, + format, + qtyBasis, + createdAtBasis, + scope, + focusDays: thresholds.focusDays, + alertDays: thresholds.alertDays, + warningLevel, + priceFocusMin: priceThresholds.priceFocusMin, + priceHighMin: priceThresholds.priceHighMin, + priceLevel, + returnLimitDays, + }); + const fileName = `${warehouseLabel}客退数据监控_${data.statDate || ''}${keyword.trim() ? `_${keyword.trim()}` : ''}.${format}`; + downloadBlobAsFile(blob, fileName); + message.success(`已开始下载:${fileName}`); + } catch (exportError) { + message.error(await readBlobErrorMessage(exportError, '导出客退数据监控失败')); + } finally { + setExporting(false); + } + }, [warehouse, warehouseLabel, keyword, supplier, data.statDate, qtyBasis, createdAtBasis, scope, thresholds, warningLevel, priceThresholds, priceLevel, returnLimitDays]); + + const importInfo = data.import; + const currentBasis = qtyBasisOptions.find((item) => item.value === qtyBasis); + const currentCreatedAtBasis = CREATED_AT_BASIS_OPTIONS.find((item) => item.value === createdAtBasis); + + // 状态页签:替代原来的三色预警大卡——一行看完「多少编码 / 多少件」,点一下即按等级过滤 + const tabItems = [ + { key: 'all', tone: 'blue', label: '全部', codes: summary.codes, qty: summary.totalQty }, + { + key: 'alert', + tone: 'red', + label: '红色预警', + hint: `在库 > ${thresholds.alertDays} 天`, + codes: summary.warning.alertCodes, + qty: summary.warning.alertQty, + }, + { + key: 'focus', + tone: 'orange', + label: '关注', + hint: `在库 ${thresholds.focusDays + 1}~${thresholds.alertDays} 天`, + codes: summary.warning.focusCodes, + qty: summary.warning.focusQty, + }, + { + key: 'normal', + tone: 'green', + label: '正常', + hint: `在库 ≤ ${thresholds.focusDays} 天`, + codes: summary.warning.normalCodes, + qty: summary.warning.normalQty, + }, + ...(summary.warning.unknownCodes > 0 ? [{ + key: 'unknown', + tone: 'grey', + label: '未知', + hint: '算不出在库天数', + codes: summary.warning.unknownCodes, + qty: summary.warning.unknownQty, + }] : []), + ]; + + const togglePriceLevel = useCallback((level) => { + setPriceLevel((current) => (current === level ? 'all' : level)); + }, []); + + /** 重置:筛选条件与所有阈值一起回到默认值(不动「件数口径 / 创建时间口径 / 范围」) */ + const resetFilters = useCallback(() => { + setKeyword(''); + setSupplier(undefined); + setWarningLevel('all'); + setPriceLevel('all'); + setFocusDays(DEFAULT_FOCUS_DAYS); + setAlertDays(DEFAULT_ALERT_DAYS); + setPriceFocusMin(DEFAULT_PRICE_FOCUS_MIN); + setPriceHighMin(DEFAULT_PRICE_HIGH_MIN); + setReturnLimitDays(DEFAULT_RETURN_LIMIT_DAYS); + }, []); + + const handlePriceFocusChange = useCallback((value) => { + setPriceFocusMin(clampPrice(value, DEFAULT_PRICE_FOCUS_MIN)); + }, []); + + const handlePriceHighChange = useCallback((value) => { + setPriceHighMin(clampPrice(value, DEFAULT_PRICE_HIGH_MIN)); + }, []); + + const handleReturnLimitChange = useCallback((value) => { + setReturnLimitDays(clampReturnLimit(value, DEFAULT_RETURN_LIMIT_DAYS)); + }, []); + + return ( + <> + {/* ── 状态页签:原来的一排三色预警大卡改成一行页签,点一下即按等级过滤 ── */} +
+ {tabItems.map((item) => ( + + ))} +
+ + {/* ── 筛选:常用项放在明面,口径类收进「更多筛选」 ────────── */} +
+ } + placeholder="商品编码 / 供应商" + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + style={{ width: 200 }} + /> + ({ value: item.value, label: item.label }))} + /> + + + + 创建时间口径 + + + + + +
+ ) : null} + + {/* 单价预警筛选是在折叠区里点的,这里回显成一个可关闭的标签,避免「筛了却看不见」 */} + {priceLevel !== 'all' ? ( +
+ 已选筛选 + } onClose={() => setPriceLevel('all')}> + 单价预警:{(PRICE_LEVEL_TAGS[priceLevel] || {}).label || priceLevel} + +
+ ) : null} + + {error ? ( + load()}>重试} + /> + ) : null} + + {importInfo ? ( + + + + )} + /> + ) : null} + + {/* 退厂要求提示:只在「重点关注」有编码时出现,是这页唯一必须置顶的提醒 */} + {summary.returnDeadline.highCodes > 0 ? ( +
0 ? ' is-overdue' : ''}`}> + + {summary.returnDeadline.overdueCodes > 0 ? : } + +
+
+ 重点关注商品退厂要求:单价 > ¥{money(priceThresholds.priceHighMin)} 的编码,须在入库后 + {returnLimitDays} 天 + 内完成库存退厂 +
+
+ 共 {num(summary.returnDeadline.highCodes)} 个编码 · {num(summary.returnDeadline.highQty)} 件 + · ¥{money(summary.returnDeadline.highAmount)} + |已超期 {num(summary.returnDeadline.overdueCodes)} 个 + ({num(summary.returnDeadline.overdueQty)} 件 · ¥{money(summary.returnDeadline.overdueAmount)})· + 剩余 {num(summary.returnDeadline.pendingCodes)} 个 · 已退厂 {num(summary.returnDeadline.doneCodes)} 个 + {summary.returnDeadline.overdueCodes > 0 + ? '。超期编码请优先安排退厂。' + : '。目前没有超期编码。'} +
+
+ +
+ ) : null} + + {/* ── 四张核心指标卡(和商品明细页同一种卡片) ───────────── */} +
+ + + 0 ? `无单价 ${num(summary.missingPriceCodes)} 个` : '全部有单价'} + tip="当前筛选条件下的商品编码数;「无单价」指商品资料里既没有成本价也没有采购价的编码(多为虚拟编码),不计入库存总金额" + /> + 0 ? 'red' : 'violet'} + label={`单价重点关注(> ${priceThresholds.priceHighMin} 元)`} + value={num(summary.priceWarning.highCodes)} + unit="个" + foot={`占金额 ¥${money(summary.priceWarning.highAmount)} · 退厂超期 ${num(summary.returnDeadline.overdueCodes)} 个`} + tip={`单价 > ${priceThresholds.priceHighMin} 元的编码数;这批编码单件占用资金最多,要求入库后 ${returnLimitDays} 天内完成库存退厂`} + /> +
+ + {/* ── 可视化分析:默认收起,一点即展开 ──────────────────── */} +
+ + {showCharts ? ( +
+ +
+ ) : null} +
+ + {/* ── 明细表 ───────────────────────────────────────────── */} +
+
+ 商品编码明细({num(filteredRows.length)} 条) + + 仓库 {data.warehouse || '—'} + {data.statDate ? ` · 统计日 ${data.statDate}` : ''} + {data.stockSyncedAt ? ` · 库存更新 ${data.stockSyncedAt}` : ''} + {summary.maxStockDays !== null && summary.maxStockDays !== undefined ? ` · 最长在库 ${summary.maxStockDays} 天` : ''} + {summary.maxUnitPrice !== null && summary.maxUnitPrice !== undefined ? ` · 最高单价 ¥${money(summary.maxUnitPrice)}` : ''} + +
+ + + ), + }} + pagination={{ + pageSize: 100, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200, 500], + showTotal: (total) => `共 ${total} 个商品编码`, + }} + /> + + + + {/* ── 口径说明:默认收起,需要时再看 ────────────────────── */} + {(data.gaps || []).length ? ( +
+ + {showNotes ? ( +
+
    + {data.gaps.map((gap) =>
  • {gap}
  • )} +
+
+ ) : null} +
+ ) : null} + + ); +}; + +export default StockWarning; diff --git a/frontend/src/pages/ReturnsInventoryWarning/StockWarningDashboard.jsx b/frontend/src/pages/ReturnsInventoryWarning/StockWarningDashboard.jsx new file mode 100644 index 0000000..754b91f --- /dev/null +++ b/frontend/src/pages/ReturnsInventoryWarning/StockWarningDashboard.jsx @@ -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 }) => ( +
+
+ {label} +
+
+ {value} + {unit ? {unit} : null} +
+
+ {tip ? {foot} : foot} +
+
+); + +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 `
${item.marker}${item.seriesName}${text}
`; + }).join(''); + return `
${tier.label}(${tier.hint})
${rowsHtml} +
在库 ${num(tier.qty)} 件
`; + }, + }, + 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) => `
${param.marker}${param.name}
+
${num(param.value)} 个编码 · 占比 ${param.percent}%
`, + }, + 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 `
${item.name}
+
库存金额 ¥${money(item.amount)}
+
${num(item.codes)} 个编码 · ${num(item.qty)} 件
`; + }, + }, + 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 `
${row.productCode}
+
库存金额 ¥${money(row.amount)}
+
单价 ¥${money(row.unitPrice)} · ${tone.label} · ${num(row.inboundQty)} 件
+
供应商 ${row.supplier || '—'}
`; + }, + }, + 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 ( + <> +
+
+ 关注 ≥ + + 重点关注 > + + ${returnLimit} 天即为「已超期」`}> + 退厂时限 + + +
+
+ {priceBar.map((item) => ( + +
onPriceLevelToggle && onPriceLevelToggle(item.key)} + onKeyDown={(e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + if (onPriceLevelToggle) onPriceLevelToggle(item.key); + }} + > + {item.label} + {num(item.codes)}个编码 + {num(item.qty)} 件 · ¥{money(item.amount)} +
+
+ ))} +
+
+ + {hasRows ? ( +
+
+

+ 库龄分布 + 分档跟随上方「关注 / 预警」天数自动调整 +

+ +
+ +
+

+ 单价预警分布 + 按编码数占比 +

+ +
+ +
+

+ 供应商库存金额 TOP10 + 压资金最多的供应商 +

+ {topSuppliers.length ? ( + + ) : } +
+ +
+

+ 商品编码库存金额 TOP10 + 柱子颜色 = 该编码的单价预警等级 +

+ {topCodes.length ? ( + + ) : } +
+
+ ) : ( + + )} + + ); +}; + +export default StockWarningDashboard; diff --git a/frontend/src/pages/ReturnsInventoryWarning/monitor.css b/frontend/src/pages/ReturnsInventoryWarning/monitor.css new file mode 100644 index 0000000..b8a3ce5 --- /dev/null +++ b/frontend/src/pages/ReturnsInventoryWarning/monitor.css @@ -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)); + } +} diff --git a/frontend/src/pages/ReturnsInventoryWarning/warehouses.js b/frontend/src/pages/ReturnsInventoryWarning/warehouses.js new file mode 100644 index 0000000..9af8c32 --- /dev/null +++ b/frontend/src/pages/ReturnsInventoryWarning/warehouses.js @@ -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) +); diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 0c64a0c..cd0ec68 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -19,6 +19,7 @@ const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncent const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); const MkhBomData = lazy(() => import('../pages/MkhBomData')); +const ReturnsInventoryMonitor = lazy(() => import('../pages/ReturnsInventoryWarning/Monitor')); const lazyLoadingElement = ; @@ -111,6 +112,41 @@ const router = createBrowserRouter([ path: 'planning/operation/mkh-bom-data', element: renderLazyComponent(MkhBomData), }, + // 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓; + // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) + { + path: 'planning/operation/returns-inventory/henlin2', + element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henlin2' }), + }, + { + path: 'planning/operation/returns-inventory/subei', + element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'subei' }), + }, + { + path: 'planning/operation/returns-inventory/suining', + element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'suining' }), + }, + { + path: 'planning/operation/returns-inventory/shandong', + element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'shandong' }), + }, + { + path: 'planning/operation/returns-inventory/henan', + element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henan' }), + }, + // 历史路径(子看板 / 页签形态)→ 统一回到主看板 + { + path: 'planning/operation/returns-inventory/henlin2/monitor', + element: , + }, + { + path: 'planning/operation/returns-inventory/henlin2/inbound', + element: , + }, + { + path: 'planning/operation/returns-inventory/henlin2/stock', + element: , + }, { path: 'planning/operation/store-checkup', element: }, { path: 'planning/operation/review-analysis', element: }, { path: 'planning/operation/customer-service-checkup', element: }, diff --git a/frontend/src/utils/tableExport.js b/frontend/src/utils/tableExport.js index 8ef968c..44d5a0d 100644 --- a/frontend/src/utils/tableExport.js +++ b/frontend/src/utils/tableExport.js @@ -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; diff --git a/scripts/start-remote.mjs b/scripts/start-remote.mjs index b6ebf3a..4b64d07 100644 --- a/scripts/start-remote.mjs +++ b/scripts/start-remote.mjs @@ -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) => { From 30f5df220619ba5e0bc1289fc6f46893f5f37e96 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 13:15:53 +0800 Subject: [PATCH 2/7] fix: auto refresh MKH live data --- frontend/src/pages/MkhBomData/index.jsx | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 9e434cb..184de96 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -33,6 +33,9 @@ const number = (value, digits = 0) => (value === null || value === undefined || const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; +// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询 +// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。 +const AUTO_REFRESH_MS = 2 * 60 * 1000; // 预警等级 → 标签配色 const WARNING_TONE = { @@ -277,6 +280,8 @@ const MkhBomData = () => { const tableWrapRef = useRef(null); const listRequestGateRef = useRef(createRequestGate()); const detailGenerationRef = useRef(0); + const lastListRefreshAtRef = useRef(0); + const autoRefreshPendingRef = useRef(false); const [bodyHeight, setBodyHeight] = useState(420); useEffect(() => { @@ -313,10 +318,10 @@ const MkhBomData = () => { return params; }, [page, pageSize, sorter, filters]); - const load = useCallback(async () => { + const load = useCallback(async ({ background = false } = {}) => { const requestGeneration = listRequestGateRef.current.begin(); - setLoading(true); - setError(''); + if (!background) setLoading(true); + if (!background) setError(''); try { const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() }); if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; @@ -325,16 +330,38 @@ const MkhBomData = () => { return; } setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); + lastListRefreshAtRef.current = Date.now(); + setError(''); } catch (requestError) { if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); } finally { - if (listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); + if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); } }, [query]); useEffect(() => { load(); }, [load]); + // 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。 + // 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。 + useEffect(() => { + const refreshIfStale = () => { + if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return; + if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return; + autoRefreshPendingRef.current = true; + load({ background: true }).finally(() => { + autoRefreshPendingRef.current = false; + }); + }; + + const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS); + document.addEventListener('visibilitychange', refreshIfStale); + return () => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refreshIfStale); + }; + }, [load, syncing]); + const changeFilter = (key) => (value) => { setFilters((old) => ({ ...old, [key]: value || '' })); setPage(1); From ea312d90700280fcb671d35d0b87b18e670f7b14 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 13:30:09 +0800 Subject: [PATCH 3/7] fix: force fresh MKH operational data --- backend/src/services/mkhBomService.js | 87 +++++++++++++++++++++++-- backend/test/mkhBomService.test.js | 50 ++++++++++++++ frontend/src/pages/MkhBomData/index.jsx | 14 +++- 3 files changed, 142 insertions(+), 9 deletions(-) diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index c5fdbb0..3ea8996 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -376,6 +376,14 @@ let productBomCache = null; */ let cacheEpoch = 0; +// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰 +// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工, +// 又避免用户每刷新一次就把所有稳定表全量重扫。 +let operationalCacheEpoch = 0; +let operationalRefreshPending = null; +let operationalRefreshedAt = 0; +const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000; + // MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。 // 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。 const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; @@ -1405,15 +1413,16 @@ async function loadPendingWorkOrderIndex() { }); const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; workOrderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - if (epoch === cacheEpoch) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; } return value; } catch (error) { - if (epoch === cacheEpoch) workOrderIndexCache = null; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null; throw error; } } @@ -1700,15 +1709,16 @@ async function loadOrderIndex() { }); const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; orderIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - if (epoch === cacheEpoch) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; } return value; } catch (error) { - if (epoch === cacheEpoch) orderIndexCache = null; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null; throw error; } } @@ -1818,15 +1828,16 @@ async function loadReplenishIndex() { }); const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; replenishIndexCache = { pending, value: null, expiresAt: 0 }; try { const value = await pending; - if (epoch === cacheEpoch) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; } return value; } catch (error) { - if (epoch === cacheEpoch) replenishIndexCache = null; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null; throw error; } } @@ -2178,7 +2189,12 @@ async function getMkhBomList(query = {}) { // 默认按可用天数升序 —— 最紧急的排最前 const sortField = String(query.sortField || 'stockDays'); const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; - const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh); + const forceFresh = parseFlag(query.fresh); + const preferLiveData = parseFlag(query.live) || forceFresh; + + // fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户 + // 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。 + if (forceFresh) await refreshMkhOperationalData(); const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ buildMergedReplenishRows({ preferLiveData }), @@ -2255,6 +2271,9 @@ async function getMkhBomList(query = {}) { saleWindowDays: SALE_WINDOW_DAYS, dataMode: preferLiveData ? 'live' : 'snapshot', refreshedAt: new Date().toISOString(), + operationalRefreshedAt: operationalRefreshedAt + ? new Date(operationalRefreshedAt).toISOString() + : null, // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 workOrderDegraded: replenishIndex.workOrderDegraded === true, workOrderWarehouse: WORK_ORDER_WAREHOUSE, @@ -2496,11 +2515,60 @@ async function listMkhBomExportRows(query = {}) { // 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照, // 而不是 2~15 分钟前的缓存结果。 // 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 +function clearMkhOperationalCache() { + operationalCacheEpoch += 1; + operationalRefreshedAt = 0; + replenishIndexCache = null; + workOrderIndexCache = null; + orderIndexCache = null; + return { + operationalCacheEpoch, + clearedAt: new Date().toISOString(), + }; +} + +/** + * 强制重读会变的运营数据。 + * - single-flight:并发用户共用同一次云库查询; + * - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库; + * - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。 + */ +async function refreshMkhOperationalData() { + const now = Date.now(); + if (operationalRefreshPending) { + const result = await operationalRefreshPending; + return { ...result, joined: true }; + } + if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) { + return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() }; + } + + clearMkhOperationalCache(); + const refreshEpoch = operationalCacheEpoch; + const pending = (async () => { + await loadReplenishIndex(); + if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now(); + return { + refreshed: true, + refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null, + }; + })(); + operationalRefreshPending = pending; + try { + return await pending; + } finally { + if (operationalRefreshPending === pending) operationalRefreshPending = null; + } +} + function clearMkhBomCache() { // 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询, // 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值, // 导致按钮点完反而 2~10 分钟一直显示旧数据)。 cacheEpoch += 1; + operationalCacheEpoch += 1; + operationalRefreshPending = null; + operationalRefreshedAt = 0; businessBomCache = null; officialBomIndexCache = null; @@ -2522,6 +2590,7 @@ function clearMkhBomCache() { return { syncedAt: new Date().toISOString(), cacheEpoch, + operationalCacheEpoch, cleared: cleared.map(([name, entries]) => ({ name, entries })), }; } @@ -2529,6 +2598,7 @@ function clearMkhBomCache() { module.exports = { getMkhBomList, clearMkhBomCache, + refreshMkhOperationalData, getMkhBomDetail, listReplenishAlertRows, listMkhBomExportRows, @@ -2590,5 +2660,8 @@ module.exports = { EXPORT_MODES, buildProductExportRows, buildComponentExportRows, + clearMkhOperationalCache, + refreshMkhOperationalData, + OPERATIONAL_REFRESH_DEDUPE_MS, }, }; diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js index 460ffd1..4f380e5 100644 --- a/backend/test/mkhBomService.test.js +++ b/backend/test/mkhBomService.test.js @@ -1285,3 +1285,53 @@ test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', asyn svc.clearMkhBomCache(); } }); + +test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + + let queryCount = 0; + let releaseMonitor; + let markMonitorStarted; + const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; }); + const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; }); + + cloudDatabase.query = async (sql) => { + queryCount += 1; + if (sql.includes('pm_stockMonitor')) { + markMonitorStarted(); + await monitorGate; + } + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ + input() { return this; }, + async query() { queryCount += 1; return { recordset: [] }; }, + }), + }); + + try { + svc.clearMkhBomCache(); + const first = svc.refreshMkhOperationalData(); + await monitorStarted; + const second = svc.refreshMkhOperationalData(); + releaseMonitor(); + const [firstResult, secondResult] = await Promise.all([first, second]); + assert.equal(firstResult.refreshed, true); + assert.equal(secondResult.joined, true); + assert.equal(secondResult.refreshedAt, firstResult.refreshedAt); + + const afterConcurrent = queryCount; + const reused = await svc.refreshMkhOperationalData(); + assert.equal(reused.reused, true); + assert.equal(queryCount, afterConcurrent, + '15 秒保护窗内不应重复访问业务云库'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 184de96..e74f650 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -323,14 +323,24 @@ const MkhBomData = () => { if (!background) setLoading(true); if (!background) setError(''); try { - const result = await getMkhBomList({ ...query, live: 1, _refresh: Date.now() }); + const now = Date.now(); + const forceFresh = !lastListRefreshAtRef.current + || now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS; + // 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。 + // 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。 + const result = await getMkhBomList({ + ...query, + live: 1, + ...(forceFresh ? { fresh: 1 } : null), + _refresh: now, + }); if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; if (!result || result.success === false) { setError(result?.error || '加载 MKH-BOM 资料失败'); return; } setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); - lastListRefreshAtRef.current = Date.now(); + if (forceFresh) lastListRefreshAtRef.current = Date.now(); setError(''); } catch (requestError) { if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; From 22e7264438add37ba8494efbce0adc0f1ef24d5c Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 13:36:44 +0800 Subject: [PATCH 4/7] feat: show MKH refresh progress --- frontend/src/pages/MkhBomData/index.jsx | 69 ++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index e74f650..46ebc7a 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch, + Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch, Table, Tooltip, Typography, message, } from 'antd'; import { @@ -37,6 +37,19 @@ const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; // 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。 const AUTO_REFRESH_MS = 2 * 60 * 1000; +const getRefreshProgress = (elapsedSeconds) => { + if (elapsedSeconds < 3) { + return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' }; + } + if (elapsedSeconds < 15) { + return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' }; + } + return { + percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)), + stage: '正在合并最新数据与 BOM 配件结果…', + }; +}; + // 预警等级 → 标签配色 const WARNING_TONE = { 缺货: 'danger', @@ -266,6 +279,7 @@ const MkhBomData = () => { // 导出(当前筛选条件下的全部数据) const [exporting, setExporting] = useState(false); const [syncing, setSyncing] = useState(false); + const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' }); // 补货预警推送设置 const [alertOpen, setAlertOpen] = useState(false); @@ -280,10 +294,35 @@ const MkhBomData = () => { const tableWrapRef = useRef(null); const listRequestGateRef = useRef(createRequestGate()); const detailGenerationRef = useRef(0); + const refreshProgressGenerationRef = useRef(0); const lastListRefreshAtRef = useRef(0); const autoRefreshPendingRef = useRef(false); const [bodyHeight, setBodyHeight] = useState(420); + const beginRefreshProgress = useCallback((title) => { + refreshProgressGenerationRef.current += 1; + const generation = refreshProgressGenerationRef.current; + setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation }); + return generation; + }, []); + + const endRefreshProgress = useCallback((generation) => { + if (generation !== refreshProgressGenerationRef.current) return; + setRefreshProgress((current) => ({ ...current, open: false })); + }, []); + + useEffect(() => { + if (!refreshProgress.open) return undefined; + const startedAt = Date.now(); + const timer = window.setInterval(() => { + setRefreshProgress((current) => ({ + ...current, + elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)), + })); + }, 1000); + return () => window.clearInterval(timer); + }, [refreshProgress.open]); + useEffect(() => { const el = tableWrapRef.current; if (!el) return undefined; @@ -320,12 +359,16 @@ const MkhBomData = () => { const load = useCallback(async ({ background = false } = {}) => { const requestGeneration = listRequestGateRef.current.begin(); + let showProgress = false; + let progressGeneration = null; if (!background) setLoading(true); if (!background) setError(''); try { const now = Date.now(); const forceFresh = !lastListRefreshAtRef.current || now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS; + showProgress = forceFresh && !background; + if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据'); // 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。 // 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。 const result = await getMkhBomList({ @@ -347,8 +390,9 @@ const MkhBomData = () => { setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); } finally { if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); + if (showProgress) endRefreshProgress(progressGeneration); } - }, [query]); + }, [beginRefreshProgress, endRefreshProgress, query]); useEffect(() => { load(); }, [load]); @@ -850,6 +894,7 @@ const MkhBomData = () => { const handleSync = async () => { setSyncing(true); setLoading(true); + const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据'); // 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。 listRequestGateRef.current.invalidate(); detailGenerationRef.current += 1; @@ -874,11 +919,31 @@ const MkhBomData = () => { } finally { if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false); setSyncing(false); + endRefreshProgress(progressGeneration); } }; + const progressView = getRefreshProgress(refreshProgress.elapsedSeconds); + return ( + + + + {progressView.stage} + 已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。 + 进度为查询阶段估算,实际完成以服务端返回为准。 + +
MKH-BOM 资料 From ba6ef97a408974ae0ab98db4a8c0d40b001392c2 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 14:02:24 +0800 Subject: [PATCH 5/7] fix: reject stale MKH backend responses --- README.md | 2 ++ backend/src/services/mkhBomService.js | 4 ++++ frontend/src/pages/MkhBomData/index.jsx | 12 ++++++++++-- frontend/src/utils/mkhFreshContract.js | 15 +++++++++++++++ frontend/test/mkhFreshContract.test.mjs | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/mkhFreshContract.js create mode 100644 frontend/test/mkhFreshContract.test.mjs diff --git a/README.md b/README.md index c51db3c..c36d8af 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ npm run remote:dev 首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。 +拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。 + ## 验证与构建 ```bash diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js index 3ea8996..f585725 100644 --- a/backend/src/services/mkhBomService.js +++ b/backend/src/services/mkhBomService.js @@ -2271,6 +2271,10 @@ async function getMkhBomList(query = {}) { saleWindowDays: SALE_WINDOW_DAYS, dataMode: preferLiveData ? 'live' : 'snapshot', refreshedAt: new Date().toISOString(), + // 前后端实时刷新协议。本地前端如果代理到未重启的旧后端, + // 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。 + freshDataContractVersion: 1, + freshDataApplied: forceFresh && operationalRefreshedAt > 0, operationalRefreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null, diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 46ebc7a..56e7043 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -18,6 +18,7 @@ import { syncMkhBom, } from '../../api/mkhBom'; import { createRequestGate } from '../../utils/requestGate'; +import { requireMkhFreshContract } from '../../utils/mkhFreshContract'; import { exportTableFile } from '../../utils/tableExport'; import './style.css'; @@ -382,7 +383,14 @@ const MkhBomData = () => { setError(result?.error || '加载 MKH-BOM 资料失败'); return; } - setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 }); + const snapshot = result?.data || null; + try { + requireMkhFreshContract(snapshot, { requireApplied: forceFresh }); + } catch (contractError) { + setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false }); + throw contractError; + } + setData(snapshot); if (forceFresh) lastListRefreshAtRef.current = Date.now(); setError(''); } catch (requestError) { @@ -908,7 +916,7 @@ const MkhBomData = () => { if (!result || result.success === false || !result?.data?.snapshot) { throw new Error(result?.error || '同步接口未返回数据库快照'); } - setData(result.data.snapshot); + setData(requireMkhFreshContract(result.data.snapshot)); setError(''); message.success('已实时读取最新订单、库存、BOM 与仓位数据'); } catch (syncError) { diff --git a/frontend/src/utils/mkhFreshContract.js b/frontend/src/utils/mkhFreshContract.js new file mode 100644 index 0000000..2ba828d --- /dev/null +++ b/frontend/src/utils/mkhFreshContract.js @@ -0,0 +1,15 @@ +export const MKH_FRESH_DATA_CONTRACT_VERSION = 1; + +const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。' + + '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。'; + +export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) { + const contractVersion = Number(snapshot?.freshDataContractVersion) || 0; + if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) { + throw new Error(OUTDATED_BACKEND_MESSAGE); + } + if (requireApplied && snapshot?.freshDataApplied !== true) { + throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。'); + } + return snapshot; +} diff --git a/frontend/test/mkhFreshContract.test.mjs b/frontend/test/mkhFreshContract.test.mjs new file mode 100644 index 0000000..261f79d --- /dev/null +++ b/frontend/test/mkhFreshContract.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js'; + +test('MKH fresh contract accepts a backend-confirmed live refresh', () => { + const snapshot = { freshDataContractVersion: 1, freshDataApplied: true }; + assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot); +}); + +test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => { + assert.throws( + () => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }), + /本地后端版本过旧/ + ); +}); + +test('MKH fresh contract requires explicit refresh completion for fresh requests', () => { + assert.throws( + () => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }), + /未确认已重读/ + ); +}); From 345214daed50a017525c9284433dc4c0e302bf16 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 14:49:08 +0800 Subject: [PATCH 6/7] fix: force live refresh on MKH sync --- backend/src/api/mkhBom.js | 9 ++++++++- backend/test/mkhBomApi.test.js | 13 +++++++++++++ frontend/src/pages/MkhBomData/index.jsx | 3 ++- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 backend/test/mkhBomApi.test.js diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js index 252019d..a4aa503 100644 --- a/backend/src/api/mkhBom.js +++ b/backend/src/api/mkhBom.js @@ -29,6 +29,10 @@ function sendError(res, error, fallback) { res.status(status).json({ success: false, error: error.message || fallback }); } +function buildSyncQuery(query = {}) { + return { ...query, live: '1', fresh: '1' }; +} + // MKH-BOM 资料列表(模块化组合商品 + 配件汇总) router.get('/list', async (req, res) => { try { @@ -109,7 +113,9 @@ router.put('/notification-center', async (req, res) => { router.post('/sync', async (req, res) => { try { const cache = clearMkhBomCache(); - const snapshot = await getMkhBomList({ ...(req.body?.query || {}), live: '1' }); + // fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。 + // 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。 + const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query)); res.set('Cache-Control', 'no-store'); res.json({ success: true, data: { cache, snapshot } }); } catch (error) { @@ -118,3 +124,4 @@ router.post('/sync', async (req, res) => { }); module.exports = router; +module.exports._private = { buildSyncQuery }; diff --git a/backend/test/mkhBomApi.test.js b/backend/test/mkhBomApi.test.js new file mode 100644 index 0000000..8d333f7 --- /dev/null +++ b/backend/test/mkhBomApi.test.js @@ -0,0 +1,13 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { _private } = require('../src/api/mkhBom'); + +test('manual MKH sync always forces a live operational refresh', () => { + assert.deepEqual( + _private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }), + { page: 2, fresh: '1', live: '1' } + ); +}); diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx index 56e7043..993ac5f 100644 --- a/frontend/src/pages/MkhBomData/index.jsx +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -916,7 +916,8 @@ const MkhBomData = () => { if (!result || result.success === false || !result?.data?.snapshot) { throw new Error(result?.error || '同步接口未返回数据库快照'); } - setData(requireMkhFreshContract(result.data.snapshot)); + setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true })); + lastListRefreshAtRef.current = Date.now(); setError(''); message.success('已实时读取最新订单、库存、BOM 与仓位数据'); } catch (syncError) { From b0f56b27f575fd1127ea6a90817c36fbe48386bf Mon Sep 17 00:00:00 2001 From: lunanting Date: Fri, 25 Sep 2026 14:16:11 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E5=AE=A2=E9=80=80=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E9=A6=96=E4=BB=93=E6=94=B9=E4=B8=BA=E5=B4=94=E6=A1=A5=E5=B7=A5?= =?UTF-8?q?=E5=8E=82=E4=BB=93=EF=BC=88=E6=95=B0=E6=8D=AE=E6=BA=90=EF=BC=9A?= =?UTF-8?q?=E5=B4=94=E6=A1=A5=E5=B7=A5=E5=8E=82=E5=AE=A2=E9=80=80=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E4=B8=8A=E6=9E=B6=E4=BB=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 2026-09-25 业务口径,第一张子看板由「横林二仓客退数据监控」改为 「崔桥工厂仓客退数据监控」,取数仓位同步改为聚水潭「崔桥工厂客退临时上架仓」。 - 仓库配置:简尚-崔桥工厂仓 wms_co_id=15038426,客退位 customize_qty_1 - key 由 henglin2 改为 cuiqiao,同步改路由 / 侧边栏 / 路由映射 / 权限条目 - 旧路径 returns-inventory/henlin2* 保留为重定向,避免历史链接 404 - 核销看板(returnMonitorService)仍指向横林二仓,前端入口已下线,本次未动 副本取证:该仓客退位当前只有 2 编码 / 2 件(退货单有量:ERP 3115 条 + 奇门 1812 条),属「客退上架库存未同步进副本」,页面需靠导入功能补数据。 --- backend/src/api/returnStockWarning.js | 2 +- backend/src/index.js | 4 +- backend/src/jobs/returnMonitorJob.js | 2 +- .../services/returnStockWarehouseRegistry.js | 43 ++++++++++++------- frontend/src/api/returnMonitor.js | 2 +- frontend/src/api/returnStockWarning.js | 2 +- frontend/src/config/sidebarNavigation.js | 2 +- frontend/src/constants/sidebarPages.js | 2 +- frontend/src/layouts/BasicLayout.jsx | 6 +-- .../pages/ReturnsInventoryWarning/Monitor.jsx | 4 +- .../ReturnsInventoryWarning/StockWarning.jsx | 2 +- .../ReturnsInventoryWarning/warehouses.js | 19 ++++---- frontend/src/router/index.jsx | 18 +++++--- 13 files changed, 64 insertions(+), 44 deletions(-) diff --git a/backend/src/api/returnStockWarning.js b/backend/src/api/returnStockWarning.js index 613a83a..7863427 100644 --- a/backend/src/api/returnStockWarning.js +++ b/backend/src/api/returnStockWarning.js @@ -25,7 +25,7 @@ const resolveScope = (value) => (['imported', 'replica'].includes(String(value)) /** * GET /api/return-stock-warning/list —— 退货库存预警列表(客退数据监控,按仓库出数) - * warehouse: 仓库标识 henlin2 / subei / suining / shandong / henan(缺省 = 横林二仓) + * warehouse: 仓库标识 cuiqiao / subei / suining / shandong / henan(缺省 = 崔桥工厂仓) */ router.get('/list', async (req, res, next) => { try { diff --git a/backend/src/index.js b/backend/src/index.js index 7ee85f3..26d6bab 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -665,9 +665,9 @@ async function startServer(options = {}) { cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes); - // 横林二仓客退:退货入库核销 + 每日库存监控 + // 崔桥工厂仓客退:退货入库核销 + 每日库存监控 cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes); - // 横林二仓客退:退货库存预警(箱及仓位库存 · 客退临时上架仓) + // 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓) cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes); // ===================== diff --git a/backend/src/jobs/returnMonitorJob.js b/backend/src/jobs/returnMonitorJob.js index 8c40465..2c640ad 100644 --- a/backend/src/jobs/returnMonitorJob.js +++ b/backend/src/jobs/returnMonitorJob.js @@ -28,7 +28,7 @@ function startReturnMonitorScheduler() { cron.schedule(JOB_CRON, () => { runReturnMonitorCompare('cron').catch(() => {}); }, { timezone: TIMEZONE }); - console.log(`[Scheduler] 横林二仓客退库龄核销任务已注册: ${JOB_CRON} (${TIMEZONE})`); + console.log(`[Scheduler] 崔桥工厂仓客退库龄核销任务已注册: ${JOB_CRON} (${TIMEZONE})`); } module.exports = { startReturnMonitorScheduler, runReturnMonitorCompare }; diff --git a/backend/src/services/returnStockWarehouseRegistry.js b/backend/src/services/returnStockWarehouseRegistry.js index dda3178..ab3a110 100644 --- a/backend/src/services/returnStockWarehouseRegistry.js +++ b/backend/src/services/returnStockWarehouseRegistry.js @@ -14,13 +14,21 @@ * · 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 件 + * 仓位名 → 库存位 对照(2026-09-25 复测,条件 ISNULL(col,0) > 0): + * 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 2 编码 / 2 件 ⚠ + * 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 75 编码 / 362 件 + * 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 98 编码 / 243 件 + * 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 403 编码 / 912 件 * 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据) * + * ⚠ 崔桥工厂仓(15038426)是 2026-09-25 用户指定替换横林二仓的。复测结论: + * 副本里该仓客退位只有 2 编码 / 2 件,但退货单量很大(ERP 售后单 3,115 条 + + * 奇门退款单 1,812 条,warehouse='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存 + * 没同步进副本」。全库 218 张表里没有任何「销退仓 / 客退仓 / 上架仓」命名的独立仓 + * (jst_warehouseInfo 里 name 含「退」的为 0),销退仓只是退货单据里的 warehouse 文本值。 + * 所以崔桥这张看板在当前副本下**必然近乎空白**,要出数只能:① 让 IT 把该仓客退上架仓 + * 的库存同步进 jst_inventoryInfo 的自定义库存位;② 用页面「导入」上传聚水潭导出文件。 + * * ⚠ 副本里**没有任何仓位维度**(2026-09-17 复核:218 张表 / 5,496 个字段中,列名含 * bin / box / position / location / shelf / slot 的为 0 个;jst_sku.bin、jst_sku.other_bin、 * jst_returnAndRefundItem.box_id、jst_packInventoryInfo.bin 四个疑似字段 100% 为空)。 @@ -37,20 +45,23 @@ const RETURN_SLOT_COLUMNS = ['customize_qty_1', 'customize_qty_2', 'customize_qt const WAREHOUSE_LIST = [ { - key: 'henlin2', - // 页面标题 / 侧边栏名称 - title: '横林二仓客退数据监控', - shortName: '横林二仓', + key: 'cuiqiao', + // 页面标题 / 侧边栏名称(2026-09-25 用户指定:原「横林二仓客退数据监控」改为崔桥工厂仓) + title: '崔桥工厂仓客退数据监控', + shortName: '崔桥工厂仓', // 仓库主表 jst_warehouseInfo.wms_co_id - wmsCoId: 15033302, - warehouseName: '简尚-横林二仓', + wmsCoId: 15038426, + warehouseName: '简尚-崔桥工厂仓', // 退货单(售后单 / 奇门退款单)里的销退仓名称,仅 firstInbound 口径用到 - returnWarehouseDb: '简尚-横林二仓销退仓', - // 聚水潭「箱及仓位库存」里对应的仓位名(页面/导出的「数据来源」直接显示它) - returnBinName: '客退临时上架仓', + returnWarehouseDb: '简尚-崔桥工厂仓销退仓', + // 聚水潭「箱及仓位库存」里对应的仓位名(2026-09-25 用户指定,页面/导出直接显示它) + returnBinName: '崔桥工厂客退临时上架仓', // 客退临时上架仓占用的自定义库存位 returnSlot: 'customize_qty_1', - // 该仓当天是否有客退在库数据(2026-09-17 实测);空仓时页面给提示 + // ⚠ 2026-09-25 实测:该仓在副本 jst_inventoryInfo 里客退位只有 2 个编码 / 2 件 + //(qty 总库存 92,818 件;return_qty / c2 / c3 / sale_refund_qty 全为 0)。 + // 退货单是有的(ERP 售后单 3,115 条 + 奇门退款单 1,812 条), + // 说明是「客退上架仓」这一仓位没同步进副本,不是业务上没有退货。 hasReturnStock: true, }, { @@ -106,7 +117,7 @@ const WAREHOUSE_LIST = [ }, ]; -const DEFAULT_WAREHOUSE_KEY = 'henlin2'; +const DEFAULT_WAREHOUSE_KEY = 'cuiqiao'; const WAREHOUSE_MAP = new Map(WAREHOUSE_LIST.map((item) => [item.key, item])); diff --git a/frontend/src/api/returnMonitor.js b/frontend/src/api/returnMonitor.js index 43b4113..65f45cd 100644 --- a/frontend/src/api/returnMonitor.js +++ b/frontend/src/api/returnMonitor.js @@ -2,7 +2,7 @@ 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 } diff --git a/frontend/src/api/returnStockWarning.js b/frontend/src/api/returnStockWarning.js index 86eff31..f345030 100644 --- a/frontend/src/api/returnStockWarning.js +++ b/frontend/src/api/returnStockWarning.js @@ -3,7 +3,7 @@ import request from './request'; const LONG_TIMEOUT_MS = 10 * 60 * 1000; // 客退数据监控(箱及仓位库存 · 客退临时上架仓) -// params.warehouse: henlin2 / subei / suining / shandong / henan(缺省 = 横林二仓) +// params.warehouse: cuiqiao / subei / suining / shandong / henan(缺省 = 崔桥工厂仓) export const getReturnStockWarningList = (params = {}) => request.get( '/api/return-stock-warning/list', { params, timeout: LONG_TIMEOUT_MS } diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index e6ac086..52e71ee 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -21,7 +21,7 @@ export const DEFAULT_SIDEBAR_CONFIG = { { 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-cuiqiao', label: '崔桥工厂仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-cuiqiao', visible: true, order: 10 }, { key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 }, { key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 }, { key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 }, diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index f6df6b6..30a8803 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -5,7 +5,7 @@ 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_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, { pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index ab110ba..25f86b9 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -81,9 +81,9 @@ const SIDEBAR_ROUTE_MAP = { }, 'promotion-module': { path: '/planning/operation/promotion-module' }, 'product-monitoring': { path: '/planning/operation/product-monitoring' }, - 'returns-inventory-henlin2': { - path: '/planning/operation/returns-inventory/henlin2', - activePath: '/planning/operation/returns-inventory/henlin2', + 'returns-inventory-cuiqiao': { + path: '/planning/operation/returns-inventory/cuiqiao', + activePath: '/planning/operation/returns-inventory/cuiqiao', matchMode: 'prefix', }, 'returns-inventory-subei': { diff --git a/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx b/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx index 1d21a45..5977ffd 100644 --- a/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx +++ b/frontend/src/pages/ReturnsInventoryWarning/Monitor.jsx @@ -7,7 +7,7 @@ import './monitor.css'; /** * 客退数据监控(按仓库出数) * - * 同一个页面支撑 5 张看板:横林二仓 / 苏北 / 睢宁 / 山东 / 河南。 + * 同一个页面支撑 5 张看板:崔桥工厂仓 / 苏北 / 睢宁 / 山东 / 河南。 * 结构完全一致,差异只有「取哪个仓」——由路由传入的 warehouseKey 决定, * 页面本身不拼任何仓库相关的 SQL 或口径。 * @@ -31,7 +31,7 @@ const Monitor = ({ warehouseKey }) => { {warehouse.hasReturnStock && warehouse.returnSlot !== 'customize_qty_1' ? (

注:该仓位在业务库里占用的是 {warehouse.returnSlot} - (横林二仓 / 苏北 / 睢宁用的是 customize_qty_1),页面已按这个位取数。 + (崔桥工厂仓 / 苏北 / 睢宁用的是 customize_qty_1),页面已按这个位取数。

) : null} {!warehouse.hasReturnStock ? ( diff --git a/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx b/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx index 279e508..5562e09 100644 --- a/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx +++ b/frontend/src/pages/ReturnsInventoryWarning/StockWarning.jsx @@ -381,7 +381,7 @@ const COLUMNS = [ /** * 客退数据监控主表(按仓库出数) * warehouse: 仓库标识 henlin2 / subei / suining / shandong / henan, - * 透传给列表 / 导出 / 导入 / 清除导入四个接口;缺省走后端默认仓(横林二仓) + * 透传给列表 / 导出 / 导入 / 清除导入四个接口;缺省走后端默认仓(崔桥工厂仓) */ const StockWarning = ({ warehouse }) => { // 前端只用来定文案(导出文件名 / 空态提示),取数与口径一律以后端返回为准 diff --git a/frontend/src/pages/ReturnsInventoryWarning/warehouses.js b/frontend/src/pages/ReturnsInventoryWarning/warehouses.js index 9af8c32..86ee366 100644 --- a/frontend/src/pages/ReturnsInventoryWarning/warehouses.js +++ b/frontend/src/pages/ReturnsInventoryWarning/warehouses.js @@ -5,20 +5,21 @@ * 这里只负责「路由 → 仓库标识 → 页面标题/描述」的映射,不改动任何口径。 * * 各仓的「客退上架仓」在聚水潭里的仓位名不同,占用的自定义库存位也不同 - * (横林/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),文案里要如实写出来, - * 否则看数据的人会对不上聚水潭。仓位名以用户 2026-09-17 口径为准。 + * (崔桥/苏北/睢宁 = customize_qty_1,山东 = customize_qty_2),文案里要如实写出来, + * 否则看数据的人会对不上聚水潭。仓位名以用户口径为准(2026-09-25 起首仓改为崔桥工厂仓)。 */ -export const DEFAULT_WAREHOUSE_KEY = 'henlin2'; +export const DEFAULT_WAREHOUSE_KEY = 'cuiqiao'; export const WAREHOUSES = [ { - key: 'henlin2', - title: '横林二仓客退数据监控', - shortName: '横林二仓', - warehouseName: '简尚-横林二仓', - returnBinName: '客退临时上架仓', + key: 'cuiqiao', + title: '崔桥工厂仓客退数据监控', + shortName: '崔桥工厂仓', + warehouseName: '简尚-崔桥工厂仓', + returnBinName: '崔桥工厂客退临时上架仓', returnSlot: 'customize_qty_1', + // 2026-09-25 实测:副本里该仓客退位只有 2 编码 / 2 件(退货单有量,库存没同步) hasReturnStock: true, }, { @@ -62,7 +63,7 @@ export const WAREHOUSES = [ const WAREHOUSE_MAP = new Map(WAREHOUSES.map((item) => [item.key, item])); -/** 取仓库配置,非法 key 回落到默认仓(横林二仓) */ +/** 取仓库配置,非法 key 回落到默认仓(崔桥工厂仓) */ export const resolveWarehouseConfig = (key) => ( WAREHOUSE_MAP.get(String(key || '').trim()) || WAREHOUSE_MAP.get(DEFAULT_WAREHOUSE_KEY) ); diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 5b297d6..c20985e 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -120,8 +120,8 @@ const router = createBrowserRouter([ // 客退数据监控(5 张看板共用同一个页面,靠 warehouseKey 区分取哪个仓; // 单一「退货库存预警」视图,原 明细/超期/汇总/差异 四个页签已下线) { - path: 'planning/operation/returns-inventory/henlin2', - element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henlin2' }), + path: 'planning/operation/returns-inventory/cuiqiao', + element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'cuiqiao' }), }, { path: 'planning/operation/returns-inventory/subei', @@ -140,17 +140,25 @@ const router = createBrowserRouter([ element: renderLazyComponent(ReturnsInventoryMonitor, { warehouseKey: 'henan' }), }, // 历史路径(子看板 / 页签形态)→ 统一回到主看板 + { + path: 'planning/operation/returns-inventory/henlin2', + element: , + }, { path: 'planning/operation/returns-inventory/henlin2/monitor', - element: , + element: , }, { path: 'planning/operation/returns-inventory/henlin2/inbound', - element: , + element: , }, { path: 'planning/operation/returns-inventory/henlin2/stock', - element: , + element: , + }, + { + path: 'planning/operation/returns-inventory/cuiqiao/monitor', + element: , }, { path: 'planning/operation/store-checkup', element: }, { path: 'planning/operation/review-analysis', element: },