cangku/backend/src/services/returnStockWarningService.js
lunanting b3f4d1af64 feat: 客退数据监控五仓看板与本地运行稳定性加固
功能:
- 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板,
  按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2)
- 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商
  (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入)
- 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单
- 服务端导出(xlsx/csv),不受列表分页与展示上限约束

稳定性:
- 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500
- 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML
- 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗
- 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时

合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现,
本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
2026-09-23 11:32:16 +08:00

1246 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<productCode, {costPrice, purchasePrice}>。
*/
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,
};